diff --git a/cpp/implementations/plain/plain_logos_object.cpp b/cpp/implementations/plain/plain_logos_object.cpp index 84251ab..5bcfcac 100644 --- a/cpp/implementations/plain/plain_logos_object.cpp +++ b/cpp/implementations/plain/plain_logos_object.cpp @@ -33,7 +33,24 @@ QVariant PlainLogosObject::callMethod(const QString& authToken, const QVariantList& args, int timeoutMs) { - if (!m_conn || !m_conn->isOpen()) return QVariant(); + // Adapter over the error-carrying implementation: discards the diagnosis, + // which is exactly what this entry point has always done. + return callMethodWithError(authToken, methodName, args, timeoutMs, nullptr); +} + +QVariant PlainLogosObject::callMethodWithError(const QString& authToken, + const QString& methodName, + const QVariantList& args, + int timeoutMs, + logos::CallError* err) +{ + if (err) err->clear(); + if (!m_conn || !m_conn->isOpen()) { + if (err) + *err = logos::callErrorTransport( + m_objectName, "connection to '" + m_objectName + "' is not open"); + return QVariant(); + } // Subscribe to the completion channel BEFORE sending, so a "multi" provider's // completion can't race ahead of the waiter (it's buffered either way). @@ -50,12 +67,22 @@ QVariant PlainLogosObject::callMethod(const QString& authToken, if (fut.wait_for(std::chrono::milliseconds(timeoutMs)) != std::future_status::ready) { qWarning() << "PlainLogosObject::callMethod: timeout for" << methodName; + if (err) + *err = logos::callErrorTimeout(m_objectName, methodName.toStdString(), + timeoutMs); return QVariant(); } auto res = fut.get(); if (!res.ok) { qWarning() << "PlainLogosObject::callMethod:" << methodName << "failed:" << QString::fromStdString(res.err); + // res.errCode / res.err have been on the wire since the plain transport + // existed; this is the first caller to keep them. MODULE_NOT_LOADED in + // particular is how "the module isn't there" reaches us on this + // transport — requestObject never checks publication — so without this + // the single most common failure was reported as a null result. + if (err) + *err = logos::callErrorFromWire(m_objectName, res.errCode, res.err); return QVariant(); } const QVariant value = rpcValueToQVariant(res.value); @@ -64,7 +91,7 @@ QVariant PlainLogosObject::callMethod(const QString& authToken, { QString callId; if (logos::isPendingCallSentinel(value, &callId)) - return awaitCompletion(callId, timeoutMs); + return awaitCompletion(callId, timeoutMs, methodName, err); } return value; } @@ -90,15 +117,21 @@ void PlainLogosObject::ensureCompletionSub() }); } -QVariant PlainLogosObject::awaitCompletion(const QString& callId, int timeoutMs) +QVariant PlainLogosObject::awaitCompletion(const QString& callId, int timeoutMs, + const QString& methodName, + logos::CallError* err) { std::unique_lock lk(m_completionMu); + const auto effectiveMs = timeoutMs > 0 ? timeoutMs : 30000; const auto deadline = std::chrono::steady_clock::now() - + std::chrono::milliseconds(timeoutMs > 0 ? timeoutMs : 30000); + + std::chrono::milliseconds(effectiveMs); const bool got = m_completionCv.wait_until(lk, deadline, [&] { return m_completions.count(callId) > 0; }); if (!got) { qWarning() << "PlainLogosObject: deferred call" << callId << "timed out"; + if (err) + *err = logos::callErrorTimeout(m_objectName, methodName.toStdString(), + effectiveMs); return QVariant(); } const QVariant result = m_completions[callId]; @@ -118,14 +151,15 @@ namespace { // process, regardless of which worker thread completed the future. // If the application has shut down (instance() is null), we drop the // callback rather than invoke it from an arbitrary thread. -void postToQtEventLoop(PlainLogosObject::AsyncResultCallback callback, - QVariant result) +void postToQtEventLoop(PlainLogosObject::AsyncResultErrorCallback callback, + QVariant result, logos::CallError err) { QCoreApplication* app = QCoreApplication::instance(); if (!app) return; QMetaObject::invokeMethod(app, - [callback = std::move(callback), result = std::move(result)]() mutable { - callback(result); + [callback = std::move(callback), result = std::move(result), + err = std::move(err)]() mutable { + callback(result, err); }, Qt::QueuedConnection); } @@ -137,12 +171,30 @@ void PlainLogosObject::callMethodAsync(const QString& authToken, const QVariantList& args, int timeoutMs, AsyncResultCallback callback) +{ + // Adapter over the error-carrying implementation: discards the diagnosis, + // which is exactly what this entry point has always done. + if (!callback) return; + callMethodAsyncWithError(authToken, methodName, args, timeoutMs, + [cb = std::move(callback)](QVariant v, const logos::CallError&) mutable { + cb(std::move(v)); + }); +} + +void PlainLogosObject::callMethodAsyncWithError(const QString& authToken, + const QString& methodName, + const QVariantList& args, + int timeoutMs, + AsyncResultErrorCallback callback) { if (!callback) return; if (!m_conn || !m_conn->isOpen()) { // Defer even the failure path — LogosObject's contract requires // callbacks on a subsequent event-loop iteration, never inline. - postToQtEventLoop(std::move(callback), QVariant()); + postToQtEventLoop(std::move(callback), QVariant(), + logos::callErrorTransport( + m_objectName, + "connection to '" + m_objectName + "' is not open")); return; } @@ -163,22 +215,33 @@ void PlainLogosObject::callMethodAsync(const QString& authToken, // future iteration can fold this wait into the shared Asio // io_context (the connection already runs on it) so we don't spin // up a thread per pending RPC. - std::thread([this, fut, timeoutMs, callback = std::move(callback)]() mutable { + const std::string method = methodName.toStdString(); + std::thread([this, fut, timeoutMs, methodName, method, + callback = std::move(callback)]() mutable { if (fut->wait_for(std::chrono::milliseconds(timeoutMs)) != std::future_status::ready) { - postToQtEventLoop(std::move(callback), QVariant()); + postToQtEventLoop(std::move(callback), QVariant(), + logos::callErrorTimeout(m_objectName, method, + timeoutMs)); return; } auto res = fut->get(); - QVariant value = res.ok ? rpcValueToQVariant(res.value) : QVariant(); + if (!res.ok) { + postToQtEventLoop(std::move(callback), QVariant(), + logos::callErrorFromWire(m_objectName, res.errCode, + res.err)); + return; + } + QVariant value = rpcValueToQVariant(res.value); // Resolve a "multi" provider's deferred completion (sentinel → wait for // the completion event) right here on the waiter thread. + logos::CallError err; { QString callId; if (logos::isPendingCallSentinel(value, &callId)) - value = awaitCompletion(callId, timeoutMs); + value = awaitCompletion(callId, timeoutMs, methodName, &err); } - postToQtEventLoop(std::move(callback), std::move(value)); + postToQtEventLoop(std::move(callback), std::move(value), std::move(err)); }).detach(); } diff --git a/cpp/implementations/plain/plain_logos_object.h b/cpp/implementations/plain/plain_logos_object.h index 87f6df1..f03ff85 100644 --- a/cpp/implementations/plain/plain_logos_object.h +++ b/cpp/implementations/plain/plain_logos_object.h @@ -23,7 +23,7 @@ namespace logos::plain { // Owns a shared_ptr; the transport layer hands the // connection over after opening the socket. release() stops the connection. // ----------------------------------------------------------------------------- -class PlainLogosObject : public LogosObject { +class PlainLogosObject : public LogosObject, public LogosObjectErrorChannel { public: PlainLogosObject(std::string objectName, std::shared_ptr conn); @@ -40,6 +40,21 @@ public: int timeoutMs, AsyncResultCallback callback) override; + // LogosObjectErrorChannel — the real implementations. The two LogosObject + // entry points above are thin adapters that discard the error, so there is + // exactly ONE call path per direction and the two front doors cannot drift. + QVariant callMethodWithError(const QString& authToken, + const QString& methodName, + const QVariantList& args, + int timeoutMs, + logos::CallError* err) override; + + void callMethodAsyncWithError(const QString& authToken, + const QString& methodName, + const QVariantList& args, + int timeoutMs, + AsyncResultErrorCallback callback) override; + bool informModuleToken(const QString& authToken, const QString& moduleName, const QString& token, @@ -61,7 +76,12 @@ private: // matching callId lands. The completion arrives on the connection's IO // thread; the caller waits on another thread — m_completionMu/Cv bridge them. void ensureCompletionSub(); - QVariant awaitCompletion(const QString& callId, int timeoutMs); + // `err` (optional) receives the timeout when the completion never lands — + // a deferred call that gives up is a timeout like any other, and used to be + // reported as a null result. + QVariant awaitCompletion(const QString& callId, int timeoutMs, + const QString& methodName = QString(), + logos::CallError* err = nullptr); std::string m_objectName; std::shared_ptr m_conn; diff --git a/cpp/implementations/qt_local/local_transport.cpp b/cpp/implementations/qt_local/local_transport.cpp index 55d1c3e..f271846 100644 --- a/cpp/implementations/qt_local/local_transport.cpp +++ b/cpp/implementations/qt_local/local_transport.cpp @@ -39,10 +39,12 @@ private: } // anonymous namespace -class LocalLogosObject : public LogosObject { +class LocalLogosObject : public LogosObject, public LogosObjectErrorChannel { public: - explicit LocalLogosObject(ModuleProxy* proxy) - : m_proxy(proxy), m_helper(nullptr) + // objectName is carried purely so a failure can name the module it belongs + // to (logos::CallError::origin). + LocalLogosObject(ModuleProxy* proxy, QString objectName) + : m_proxy(proxy), m_helper(nullptr), m_objectName(std::move(objectName)) { qDebug() << "[LogosObject] Created LocalLogosObject wrapping ModuleProxy" << reinterpret_cast(proxy); } @@ -52,31 +54,62 @@ public: delete m_helper; } + // Adapters over the error-carrying implementations: they discard the + // diagnosis, which is exactly what these entry points have always done. QVariant callMethod(const QString& authToken, const QString& methodName, const QVariantList& args, - int /*timeoutMs*/) override + int timeoutMs) override { - if (!m_proxy) return QVariant(); - qDebug() << "[LogosObject] LocalLogosObject::callMethod" << methodName << "args:" << args.size(); - return m_proxy->callRemoteMethod(authToken, methodName, args); + return callMethodWithError(authToken, methodName, args, timeoutMs, nullptr); } void callMethodAsync(const QString& authToken, const QString& methodName, const QVariantList& args, - int /*timeoutMs*/, + int timeoutMs, AsyncResultCallback callback) override { if (!callback) return; + callMethodAsyncWithError(authToken, methodName, args, timeoutMs, + [cb = std::move(callback)](QVariant v, const logos::CallError&) mutable { + cb(std::move(v)); + }); + } + + // In-process direct dispatch: there is no wire to drop and no deadline to + // miss, so a vanished ModuleProxy is the only failure this transport has. + QVariant callMethodWithError(const QString& authToken, + const QString& methodName, + const QVariantList& args, + int /*timeoutMs*/, + logos::CallError* err) override + { + if (err) err->clear(); if (!m_proxy) { - QTimer::singleShot(0, [callback]() { callback(QVariant()); }); + if (err) *err = proxyGoneError(); + return QVariant(); + } + qDebug() << "[LogosObject] LocalLogosObject::callMethod" << methodName << "args:" << args.size(); + return m_proxy->callRemoteMethod(authToken, methodName, args); + } + + void callMethodAsyncWithError(const QString& authToken, + const QString& methodName, + const QVariantList& args, + int /*timeoutMs*/, + AsyncResultErrorCallback callback) override + { + if (!callback) return; + if (!m_proxy) { + const logos::CallError e = proxyGoneError(); + QTimer::singleShot(0, [callback, e]() { callback(QVariant(), e); }); return; } ModuleProxy* proxy = m_proxy; QTimer::singleShot(0, [proxy, authToken, methodName, args, callback]() { QVariant result = proxy->callRemoteMethod(authToken, methodName, args); - callback(result); + callback(result, logos::CallError{}); }); } @@ -134,8 +167,16 @@ public: quintptr id() const override { return reinterpret_cast(m_proxy); } private: + logos::CallError proxyGoneError() const + { + const std::string origin = m_objectName.toStdString(); + return logos::callErrorObjectUnavailable( + origin, "module '" + origin + "' is no longer registered locally"); + } + ModuleProxy* m_proxy; EventHelper* m_helper; + QString m_objectName; }; // ── LocalTransportHost ─────────────────────────────────────────────────────── @@ -188,7 +229,7 @@ LogosObject* LocalTransportConnection::requestObject(const QString& objectName, } qDebug() << "[LogosObject] LocalTransportConnection: returning LocalLogosObject for:" << objectName; - return new LocalLogosObject(proxy); + return new LocalLogosObject(proxy, objectName); } #include "local_transport.moc" diff --git a/cpp/implementations/qt_remote/remote_transport.cpp b/cpp/implementations/qt_remote/remote_transport.cpp index 5f3560f..d91b449 100644 --- a/cpp/implementations/qt_remote/remote_transport.cpp +++ b/cpp/implementations/qt_remote/remote_transport.cpp @@ -57,10 +57,13 @@ private: } // anonymous namespace -class RemoteLogosObject : public LogosObject { +class RemoteLogosObject : public LogosObject, public LogosObjectErrorChannel { public: - explicit RemoteLogosObject(QObject* replica) - : m_replica(replica), m_helper(nullptr) + // objectName is carried purely so a failure can name the module it belongs + // to — logos::CallError::origin, the same field the acquire-time error and + // lp_invoke's out_error_json already fill in. + RemoteLogosObject(QObject* replica, QString objectName) + : m_replica(replica), m_helper(nullptr), m_objectName(std::move(objectName)) { qDebug() << "[LogosObject] Created RemoteLogosObject wrapping QRemoteObjectReplica" << reinterpret_cast(replica); if (m_replica) { @@ -96,7 +99,9 @@ public: // first (mirrors the QPointer guard in invokeRemoteMethodAsync). if (cb) QTimer::singleShot(0, m_helper, - [cb = std::move(cb), result]() { cb(result); }); + [cb = std::move(cb), result]() { + cb(result, logos::CallError{}); + }); } }); } @@ -115,13 +120,32 @@ public: } } + // Adapter over the error-carrying implementation: discards the diagnosis, + // which is exactly what this entry point has always done. QVariant callMethod(const QString& authToken, const QString& methodName, const QVariantList& args, int timeoutMs) override { + return callMethodWithError(authToken, methodName, args, timeoutMs, nullptr); + } + + QVariant callMethodWithError(const QString& authToken, + const QString& methodName, + const QVariantList& args, + int timeoutMs, + logos::CallError* err) override + { + if (err) err->clear(); + const std::string origin = m_objectName.toStdString(); + const std::string method = methodName.toStdString(); + if (!m_replica) { qWarning() << "RemoteLogosObject: Cannot call method on null replica"; + if (err) + *err = logos::callErrorObjectUnavailable( + origin, "replica for '" + origin + "' is gone (module unloaded" + " or transport dropped)"); return QVariant(); } qDebug() << "[LogosObject] RemoteLogosObject::callMethod" << methodName << "args:" << args.size(); @@ -139,22 +163,41 @@ public: if (!success) { qWarning() << "RemoteLogosObject: Failed to invoke callRemoteMethod on replica"; + if (err) + *err = logos::callErrorCallFailed( + origin, "replica did not accept callRemoteMethod for '" + + method + "'"); return QVariant(); } pendingCall.waitForFinished(timeoutMs); - if (!pendingCall.isFinished() || pendingCall.error() != QRemoteObjectPendingCall::NoError) { - qWarning() << "RemoteLogosObject: callRemoteMethod failed or timed out:" << pendingCall.error(); + // Two distinct outcomes that used to collapse into one empty QVariant: + // the deadline elapsed with the call still in flight (timeout), and QtRO + // itself failed the call (transport). + if (!pendingCall.isFinished()) { + qWarning() << "RemoteLogosObject: callRemoteMethod timed out"; + if (err) *err = logos::callErrorTimeout(origin, method, timeoutMs); + return QVariant(); + } + if (pendingCall.error() != QRemoteObjectPendingCall::NoError) { + qWarning() << "RemoteLogosObject: callRemoteMethod failed:" << pendingCall.error(); + if (err) + *err = logos::callErrorTransport( + origin, "QtRO call to '" + origin + "." + method + + "' failed with error " + + std::to_string(static_cast(pendingCall.error()))); return QVariant(); } // A "multi" provider may have deferred the result (returned a pending // sentinel); resolveDeferred waits for the completion event, or returns // the value unchanged for an ordinary (synchronous) result. - return resolveDeferred(pendingCall.returnValue(), timeoutMs); + return resolveDeferred(pendingCall.returnValue(), timeoutMs, methodName, err); } + // Adapter over the error-carrying implementation: discards the diagnosis, + // which is exactly what this entry point has always done. void callMethodAsync(const QString& authToken, const QString& methodName, const QVariantList& args, @@ -162,8 +205,27 @@ public: AsyncResultCallback callback) override { if (!callback) return; + callMethodAsyncWithError(authToken, methodName, args, timeoutMs, + [cb = std::move(callback)](QVariant v, const logos::CallError&) mutable { + cb(std::move(v)); + }); + } + + void callMethodAsyncWithError(const QString& authToken, + const QString& methodName, + const QVariantList& args, + int timeoutMs, + AsyncResultErrorCallback callback) override + { + if (!callback) return; + const std::string origin = m_objectName.toStdString(); + const std::string method = methodName.toStdString(); + if (!m_replica) { - QTimer::singleShot(0, [callback]() { callback(QVariant()); }); + const logos::CallError e = logos::callErrorObjectUnavailable( + origin, "replica for '" + origin + "' is gone (module unloaded" + " or transport dropped)"); + QTimer::singleShot(0, [callback, e]() { callback(QVariant(), e); }); return; } @@ -182,7 +244,9 @@ public: if (!success) { qWarning() << "RemoteLogosObject: Failed to invoke callRemoteMethod on replica (async)"; - QTimer::singleShot(0, [callback]() { callback(QVariant()); }); + const logos::CallError e = logos::callErrorCallFailed( + origin, "replica did not accept callRemoteMethod for '" + method + "'"); + QTimer::singleShot(0, [callback, e]() { callback(QVariant(), e); }); return; } @@ -195,39 +259,50 @@ public: // Success handler -- delivers result on the consumer's thread QObject::connect(watcher, &QRemoteObjectPendingCallWatcher::finished, - watcher, [this, callback, timer, timeoutMs](QRemoteObjectPendingCallWatcher* w) { + watcher, [this, callback, timer, timeoutMs, origin, method](QRemoteObjectPendingCallWatcher* w) { timer->stop(); // cancel timeout QVariant result; + logos::CallError err; if (w->error() == QRemoteObjectPendingCall::NoError) { result = w->returnValue(); } else { qWarning() << "RemoteLogosObject: async callMethod error:" << w->error(); + err = logos::callErrorTransport( + origin, "QtRO call to '" + origin + "." + method + + "' failed with error " + + std::to_string(static_cast(w->error()))); } w->deleteLater(); // A "multi" provider may have deferred the result: wait for the // completion event instead of delivering the pending sentinel. - { + if (err.ok()) { QString callId; if (logos::isPendingCallSentinel(result, &callId)) { - if (m_completions.contains(callId)) { callback(m_completions.take(callId)); return; } + if (m_completions.contains(callId)) { + callback(m_completions.take(callId), logos::CallError{}); + return; + } m_asyncCompletionCbs.insert(callId, callback); - // Bound the wait: deliver an empty result once if it never lands. - QTimer::singleShot(timeoutMs, m_helper, [this, callId]() { + // Bound the wait: a completion that never lands is a timeout, + // reported as one instead of as an empty result. + QTimer::singleShot(timeoutMs, m_helper, [this, callId, origin, method, timeoutMs]() { if (m_asyncCompletionCbs.contains(callId)) { auto cb = m_asyncCompletionCbs.take(callId); - if (cb) cb(QVariant()); + if (cb) + cb(QVariant(), + logos::callErrorTimeout(origin, method, timeoutMs)); } }); return; } } - callback(result); + callback(result, err); }, Qt::QueuedConnection); - // Timeout handler -- stops the watcher and delivers empty result - QObject::connect(timer, &QTimer::timeout, watcher, [watcher, callback]() { + // Timeout handler -- stops the watcher and reports the elapsed deadline + QObject::connect(timer, &QTimer::timeout, watcher, [watcher, callback, origin, method, timeoutMs]() { qWarning() << "RemoteLogosObject: async callMethod timed out"; - callback(QVariant()); + callback(QVariant(), logos::callErrorTimeout(origin, method, timeoutMs)); watcher->deleteLater(); // also destroys the timer (child) }); @@ -357,7 +432,9 @@ private: // Resolve a possibly-deferred result. If `rv` is a pending sentinel from a // "multi" provider, wait (up to timeoutMs) for the completion event keyed by // callId, pumping the consumer event loop; otherwise return `rv` unchanged. - QVariant resolveDeferred(const QVariant& rv, int timeoutMs) + QVariant resolveDeferred(const QVariant& rv, int timeoutMs, + const QString& methodName = QString(), + logos::CallError* err = nullptr) { QString callId; if (!logos::isPendingCallSentinel(rv, &callId)) return rv; @@ -373,6 +450,10 @@ private: m_completionWaiters.remove(callId); if (m_completions.contains(callId)) return m_completions.take(callId); qWarning() << "RemoteLogosObject: deferred call" << callId << "timed out"; + if (err) + *err = logos::callErrorTimeout(m_objectName.toStdString(), + methodName.toStdString(), + timeoutMs > 0 ? timeoutMs : 30000); return QVariant(); } @@ -383,7 +464,8 @@ private: // Touched only on the consumer event-loop thread. QHash m_completions; QHash m_completionWaiters; - QHash m_asyncCompletionCbs; + QHash m_asyncCompletionCbs; + QString m_objectName; }; // ── RemoteTransportHost ────────────────────────────────────────────────────── @@ -538,7 +620,7 @@ LogosObject* RemoteTransportConnection::requestObject(const QString& objectName, qDebug() << "[LogosObject] RemoteTransportConnection: returning RemoteLogosObject for:" << objectName; g_acquireCount.fetch_add(1, std::memory_order_relaxed); - return new RemoteLogosObject(replica); + return new RemoteLogosObject(replica, objectName); } long RemoteTransportConnection::acquireCount() { return g_acquireCount.load(std::memory_order_relaxed); } diff --git a/cpp/logos_api_consumer.cpp b/cpp/logos_api_consumer.cpp index d68581e..2c39136 100644 --- a/cpp/logos_api_consumer.cpp +++ b/cpp/logos_api_consumer.cpp @@ -141,6 +141,16 @@ QVariant LogosAPIConsumer::invokeRemoteMethod(const QString& authToken, const QS qDebug() << "[LogosObject] LogosAPIConsumer: calling via LogosObject::callMethod" << methodName; // No release() here: the handle stays cached for the next call. Released in // clearObjectCache() (destructor / reconnect) or evicted when stale. + // + // Prefer the error channel when the transport implements it (see + // LogosObjectErrorChannel in logos_object.h). Without it, `err` could only + // ever describe an ACQUIRE failure — everything that went wrong after the + // handle existed (the deadline elapsing, the connection dropping, the peer + // answering "not published") came back as a bare QVariant() with a clean + // err, i.e. reported as a method that returned null. + if (auto* channel = dynamic_cast(plugin)) + return channel->callMethodWithError(authToken, methodName, args, + timeout.ms, err); return plugin->callMethod(authToken, methodName, args, timeout.ms); } @@ -216,6 +226,23 @@ void LogosAPIConsumer::invokeRemoteMethodAsync(const QString& authToken, const Q // before the transport callback fires, the callback is silently dropped and // the handle is released by the destructor's clearObjectCache(), not here. QPointer self(this); + + // Prefer the error channel when the transport implements it. The lambda + // below used to take only `QVariant result` and hand the caller a + // hard-coded empty logos::CallError — so once acquire had succeeded, every + // async outcome was reported as a success, whatever actually happened. + if (auto* channel = dynamic_cast(plugin)) { + channel->callMethodAsyncWithError(authToken, methodName, args, timeout.ms, + [callback, self](QVariant result, const logos::CallError& err) { + if (!self) + return; + callback(std::move(result), err); + }); + return; + } + + // Transport without an error channel (the mock): unchanged behaviour — + // the value, and no diagnosis to give. plugin->callMethodAsync(authToken, methodName, args, timeout.ms, [callback, self](QVariant result) { if (!self) diff --git a/cpp/logos_call_error.h b/cpp/logos_call_error.h index 34e6564..6055c5c 100644 --- a/cpp/logos_call_error.h +++ b/cpp/logos_call_error.h @@ -16,8 +16,22 @@ namespace logos { // provider dispatch errors) without an ABI break. // // Currently produced: -// "object_unavailable" — the target module/object could not be acquired -// (not loaded, not published, or transport failure). +// "object_unavailable" — the target module/object is not there: it could not +// be acquired, or the transport answered that it is +// not published. One code for one condition, whether +// it is detected at acquire time (QtRO, which resolves +// a replica up front) or at call time (the plain wire, +// whose requestObject hands back a handle for any name +// over an open connection and only learns the truth +// from the reply). +// "timeout" — the caller's deadline elapsed with no reply. +// "transport_error" — the connection failed or was torn down mid-call. +// "call_failed" — the peer could not dispatch the call at all (as +// distinct from a provider that ran and REJECTED it, +// which answers the "dispatch_failed" envelope as its +// result value — see logos-cpp-sdk#129). +// "unauthorized" — the provider rejected our token and the one +// permitted re-exchange also failed. struct CallError { std::string code; // empty = no error std::string message; @@ -27,6 +41,59 @@ struct CallError { void clear() { code.clear(); message.clear(); origin.clear(); } }; +// --------------------------------------------------------------------------- +// Canonical constructors. +// +// Every transport that can detect one of these produces it HERE rather than +// spelling the code out at the failure site, so a caller decoding a +// {code, message, origin} object gets the same vocabulary no matter which wire +// the call went over — and so lp_invoke and lp_invoke_async, which both render +// this struct with the same makeErrorJson(), stay indistinguishable. +// --------------------------------------------------------------------------- + +inline CallError callErrorTimeout(const std::string& origin, + const std::string& method, int timeoutMs) +{ + return {"timeout", + "call to '" + origin + "." + method + "' timed out after " + + std::to_string(timeoutMs) + "ms", + origin}; +} + +inline CallError callErrorObjectUnavailable(const std::string& origin, + const std::string& detail) +{ + return {"object_unavailable", detail, origin}; +} + +inline CallError callErrorTransport(const std::string& origin, + const std::string& detail) +{ + return {"transport_error", detail, origin}; +} + +inline CallError callErrorCallFailed(const std::string& origin, + const std::string& detail) +{ + return {"call_failed", detail, origin}; +} + +// Map a plain-wire ResultMessage failure (errCode + err) onto the vocabulary +// above. The wire's codes are the transport's own spelling; this is the single +// place they are translated, so a new wire code degrades to "call_failed" +// instead of silently becoming an empty CallError (which reads as SUCCESS). +inline CallError callErrorFromWire(const std::string& origin, + const std::string& wireCode, + const std::string& message) +{ + const std::string detail = message.empty() ? wireCode : message; + if (wireCode == "MODULE_NOT_LOADED") + return callErrorObjectUnavailable(origin, detail); + if (wireCode == "TRANSPORT_CLOSED" || wireCode == "TRANSPORT_ERROR") + return callErrorTransport(origin, detail); + return callErrorCallFailed(origin, detail); +} + } // namespace logos #endif // LOGOS_CALL_ERROR_H diff --git a/cpp/logos_object.h b/cpp/logos_object.h index b3f71ba..9243ab9 100644 --- a/cpp/logos_object.h +++ b/cpp/logos_object.h @@ -1,6 +1,8 @@ #ifndef LOGOS_OBJECT_H #define LOGOS_OBJECT_H +#include "logos_call_error.h" + #include #include #include @@ -126,4 +128,71 @@ public: virtual bool isValid() const { return true; } }; +/** + * @brief Optional extension: calls that report WHY they failed. + * + * LogosObject's own callMethod/callMethodAsync answer a bare QVariant() for + * every failure — a timeout, a torn-down connection, and a module that is not + * published all look identical to a provider that legitimately returned null. + * That is the whole reason lp_invoke and lp_invoke_async could report success + * for a call that never happened. + * + * This interface is DELIBERATELY a sibling of LogosObject rather than more + * virtuals on it. LogosObject is an installed header (`include/logos_object.h`) + * whose vtable is baked into every statically-linked copy of liblogos_protocol + * in a process — one per loaded module, each pinned to its own protocol + * revision. Appending a virtual would append a vtable slot, and a caller + * compiled against the new header calling that slot on an object whose vtable + * came from an older copy is undefined behaviour. Declaring a separate + * interface and reaching it with dynamic_cast leaves LogosObject's layout, + * size and vtable byte-for-byte unchanged, so no such pairing can exist: + * a copy that does not know about this interface simply fails the cast. + * + * Consumers therefore MUST treat it as optional: + * + * if (auto* ch = dynamic_cast(obj)) + * ch->callMethodWithError(...); // real diagnosis + * else + * obj->callMethod(...); // today's behaviour, unchanged + * + * Implemented by the plain (tcp/tcp_ssl), qt_remote (QtRO) and qt_local + * transports. NOT implemented by the mock transport: MockStore always answers, + * so there is no failure to report, and leaving MockLogosObject alone keeps the + * one subclass whose header is installed (implementations/mock/mock_transport.h) + * layout-identical too. + */ +class LogosObjectErrorChannel { +public: + virtual ~LogosObjectErrorChannel() = default; + + /** + * @brief callMethod, plus the reason on failure. + * @param err Cleared on entry; set to the canonical {code, message, origin} + * on failure. May be null (then this is exactly callMethod). + * @return The method result, or an invalid QVariant on failure. + */ + virtual QVariant callMethodWithError(const QString& authToken, + const QString& methodName, + const QVariantList& args, + int timeoutMs, + logos::CallError* err) = 0; + + using AsyncResultErrorCallback = + std::function; + + /** + * @brief callMethodAsync, whose callback carries the reason on failure. + * + * Same delivery contract as LogosObject::callMethodAsync: the callback + * fires on a subsequent event-loop iteration, never synchronously, and + * exactly once. On success the error argument is a default-constructed + * (ok()) CallError. + */ + virtual void callMethodAsyncWithError(const QString& authToken, + const QString& methodName, + const QVariantList& args, + int timeoutMs, + AsyncResultErrorCallback callback) = 0; +}; + #endif // LOGOS_OBJECT_H diff --git a/tests/protocol/CMakeLists.txt b/tests/protocol/CMakeLists.txt index 587b59d..fcc50ed 100644 --- a/tests/protocol/CMakeLists.txt +++ b/tests/protocol/CMakeLists.txt @@ -36,6 +36,20 @@ add_executable(protocol_tests # canonical error object, a succeeding one must still report ok=1 with its # value. Both over a real (plain TCP) transport. test_lp_invoke_async_error.cpp + # The rest of that channel: everything the TRANSPORT learns once acquire has + # already succeeded — a timeout, and MODULE_NOT_LOADED against a host that is + # up (which is not an acquire failure on the plain transport, because + # requestObject never checks publication). Both were discarded by + # PlainLogosObject and by the hard-coded empty CallError above it, on the + # SYNC path as much as the async one. Matched set: the two failures must + # report the canonical error object, the successes must still report their + # value, and the one case that genuinely cannot be fixed here (an unknown + # method, which every provider answers with a bare null) is pinned as-is. + test_call_error_after_acquire.cpp + # The same channel over the DEFAULT transport (LocalSocket/QtRO), so the fix + # is not silently plain-TCP-only. Exercises the deferred-completion timeout, + # which needs no blocking provider and therefore no second event loop. + test_call_error_qt_remote.cpp # Component tests that moved here with their code (from logos-cpp-sdk) test_token_manager.cpp test_mock_store.cpp diff --git a/tests/protocol/test_call_error_after_acquire.cpp b/tests/protocol/test_call_error_after_acquire.cpp new file mode 100644 index 0000000..7302e27 --- /dev/null +++ b/tests/protocol/test_call_error_after_acquire.cpp @@ -0,0 +1,408 @@ +// The call-error channel AFTER the target has been acquired. +// +// logos-protocol#40 made lp_invoke_async able to report a failure at all, but +// only for the two conditions the layers ABOVE the transport produce: acquire +// failure ("object_unavailable") and the unauthorized sentinel. Everything the +// transport itself learns while the call is in flight was still discarded: +// +// * PlainLogosObject::callMethod / callMethodAsync answer a bare QVariant() +// for BOTH `future timed out` and `ResultMessage.ok == false` — throwing +// away res.err / res.errCode, which the wire already carries; +// * LogosAPIConsumer::invokeRemoteMethodAsync then hard-coded an empty +// logos::CallError next to that value. +// +// So once acquire succeeded, both entry points reported success no matter what +// happened. Two conditions in particular are ordinary, not exotic: +// +// * a TIMEOUT — the caller's own deadline elapsed and nothing came back; +// * MODULE NOT LOADED against a LIVE host. PlainTransportConnection:: +// requestObject never checks publication (it just constructs a handle over +// the open connection), so "the module isn't there" is NOT an acquire +// failure on this transport — it is a MODULE_NOT_LOADED ResultMessage at +// call time, and #40's object_unavailable never fires for it. +// +// These tests are a matched set and only mean something together: the two +// failures must report ok=0 / LP_ERR_UNAVAILABLE with a canonical +// {code,message,origin} object, and the successful control must still report +// ok=1 with its value — a fix that reported failure everywhere would satisfy +// the first half and break the second. +// +// Sync and async are BOTH covered because both had the identical hole: an +// async-only fix would leave lp_invoke lying while lp_invoke_async told the +// truth, which is the opposite of the parity #40 set out to establish. +// +// Everything runs against a real transport (plain TCP) and a live in-process +// PlainTransportHost, never the mock. + +#include + +#include "logos_protocol.h" + +#include "logos_provider_interface.h" +#include "logos_transport_config.h" +#include "module_proxy.h" + +#include "plain_transport_host.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace logos::plain; + +namespace { + +// A provider faithful to the real ones: compute() answers 7, slow() blocks +// past any sane deadline, and an UNKNOWN method answers a bare QVariant() — +// which is exactly what logos-qt-sdk's QtProviderObject and every generated +// provider dispatch do for a name they don't recognise. +class SlowProvider : public LogosProviderObject { +public: + QVariant callMethod(const QString& method, const QVariantList& args) override + { + if (method == QLatin1String("compute")) return QVariant(7); + if (method == QLatin1String("echo")) return args.value(0); + if (method == QLatin1String("slow")) { + std::this_thread::sleep_for(std::chrono::milliseconds(3000)); + return QVariant(1); + } + return QVariant(); // unknown method — indistinguishable from null + } + QJsonArray getMethods() override { return QJsonArray{}; } + bool informModuleToken(const QString&, const QString&) override { return true; } + void setEventListener(EventCallback) override {} + void init(void*) override {} + QString providerName() const override { return QStringLiteral("slow"); } + QString providerVersion() const override { return QStringLiteral("1.0.0"); } +}; + +QCoreApplication* ensureApp() +{ + static int argc = 0; + static char* argv[] = { nullptr }; + if (!QCoreApplication::instance()) + new QCoreApplication(argc, argv); + return QCoreApplication::instance(); +} + +struct Capture { + std::atomic fired{false}; + int ok = -1; + std::string json; +}; + +void captureCb(int ok, const char* json, void* user_data) +{ + auto* c = static_cast(user_data); + c->ok = ok; + c->json = json ? json : ""; + c->fired = true; +} + +bool pumpUntilFired(Capture& c, int budgetMs) +{ + QElapsedTimer timer; + timer.start(); + while (!c.fired && timer.elapsed() < budgetMs) + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + return c.fired; +} + +// A live host publishing `slow_module` through a ModuleProxy that lives on its +// OWN thread. The worker thread matters: PlainTransportHost::onCall dispatches +// to the proxy's thread, so a provider that sleeps would otherwise block the +// very event loop the consumer needs to deliver its own callback, and the test +// would measure the harness rather than the protocol. +class LiveHost { +public: + LiveHost() + { + LogosTransportConfig cfg; + cfg.protocol = LogosProtocol::Tcp; + cfg.host = "127.0.0.1"; + cfg.port = 0; // ephemeral + m_host = std::make_unique(cfg); + m_started = m_host->start(); + + m_proxy = new ModuleProxy(&m_provider); + m_proxy->saveToken(QStringLiteral("origin"), QStringLiteral("live-token")); + m_thread = new QThread; + m_proxy->moveToThread(m_thread); + m_thread->start(); + + m_published = m_host->publishObject("slow_module", m_proxy); + + const QString endpoint = m_host->endpoint(); + m_port = endpoint.mid(endpoint.lastIndexOf(':') + 1).toUShort(); + } + + ~LiveHost() + { + // Order matters: tear the host down FIRST so no inbound frame can be + // dispatched to the proxy while we are dismantling it, then stop the + // proxy's thread, then delete the proxy it was serving. + m_host.reset(); + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + m_thread->quit(); + m_thread->wait(); + delete m_proxy; + delete m_thread; + } + + bool ok() const { return m_started && m_published && m_port != 0; } + + std::string target() const + { + return "{\"protocol\":\"tcp\",\"host\":\"127.0.0.1\",\"port\":" + + std::to_string(m_port) + "}"; + } + +private: + SlowProvider m_provider; + std::unique_ptr m_host; + ModuleProxy* m_proxy = nullptr; + QThread* m_thread = nullptr; + bool m_started = false; + bool m_published = false; + uint16_t m_port = 0; +}; + +// Create an lp_client for `module` at `endpoint`, with its token pre-saved so +// the capability_module handshake is skipped and only the call path is under +// test. +lp_client* clientFor(const char* module, const std::string& endpoint) +{ + lp_token_save(module, "live-token"); + return lp_client_create(module, "origin", endpoint.c_str(), endpoint.c_str()); +} + +void destroyClient(lp_client* c) +{ + lp_client_destroy(c); + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); +} + +} // namespace + +class CallErrorAfterAcquireTest : public ::testing::Test { +protected: + void SetUp() override { ensureApp(); } +}; + +// ── control: a successful async call still reports its value ──────────────── +TEST_F(CallErrorAfterAcquireTest, AsyncSuccessStillReportsTheValue) +{ + LiveHost host; + ASSERT_TRUE(host.ok()); + + lp_client* client = clientFor("slow_module", host.target()); + ASSERT_NE(client, nullptr); + + Capture c; + ASSERT_EQ(lp_invoke_async(client, "compute", "[]", 5000, &captureCb, &c), LP_OK); + ASSERT_TRUE(pumpUntilFired(c, 15000)) << "async callback never fired"; + + std::cout << " ASYNC success -> ok=" << c.ok << " json=" << c.json << std::endl; + + EXPECT_EQ(c.ok, 1) << "a successful async call reported failure"; + nlohmann::json v = nlohmann::json::parse(c.json, nullptr, false); + ASSERT_TRUE(v.is_number()); + EXPECT_DOUBLE_EQ(v.get(), 7.0); + + destroyClient(client); +} + +// ── the caller's deadline elapsed ─────────────────────────────────────────── +// +// The provider sleeps 3s; the call is given 600ms. Pre-fix this delivered +// ok=1 with json "null" — a timeout dressed up as a provider that returned +// nothing. +TEST_F(CallErrorAfterAcquireTest, AsyncTimeoutReportsTheError) +{ + LiveHost host; + ASSERT_TRUE(host.ok()); + + lp_client* client = clientFor("slow_module", host.target()); + ASSERT_NE(client, nullptr); + + Capture c; + ASSERT_EQ(lp_invoke_async(client, "slow", "[]", 600, &captureCb, &c), LP_OK); + ASSERT_TRUE(pumpUntilFired(c, 15000)) << "async callback never fired"; + + std::cout << " ASYNC timeout -> ok=" << c.ok << " json=" << c.json << std::endl; + + EXPECT_EQ(c.ok, 0) << "a timed-out async call reported success"; + nlohmann::json e = nlohmann::json::parse(c.json, nullptr, false); + ASSERT_TRUE(e.is_object()) << "ok==0 must carry the canonical error object"; + EXPECT_EQ(e.value("code", std::string{}), "timeout"); + EXPECT_EQ(e.value("origin", std::string{}), "slow_module"); + EXPECT_FALSE(e.value("message", std::string{}).empty()); + + destroyClient(client); + // The provider is still sleeping; let it drain before the host goes away. + QElapsedTimer t; t.start(); + while (t.elapsed() < 3500) QCoreApplication::processEvents(QEventLoop::AllEvents, 50); +} + +// ── the module is not loaded, on a host that IS up ────────────────────────── +// +// The single most common real failure, and the one #40's release notes claim +// to have fixed. It does NOT go through the acquire path on this transport: +// PlainTransportConnection::requestObject hands back a handle for any name over +// an open connection, so the failure surfaces as a MODULE_NOT_LOADED +// ResultMessage at call time — which was discarded. +TEST_F(CallErrorAfterAcquireTest, AsyncModuleNotLoadedOnALiveHostReportsTheError) +{ + LiveHost host; + ASSERT_TRUE(host.ok()); + + lp_client* client = clientFor("ghost_module", host.target()); + ASSERT_NE(client, nullptr); + + Capture c; + ASSERT_EQ(lp_invoke_async(client, "compute", "[]", 5000, &captureCb, &c), LP_OK); + ASSERT_TRUE(pumpUntilFired(c, 15000)) << "async callback never fired"; + + std::cout << " ASYNC not-published -> ok=" << c.ok << " json=" << c.json << std::endl; + + EXPECT_EQ(c.ok, 0) << "a call to an unpublished module reported success"; + nlohmann::json e = nlohmann::json::parse(c.json, nullptr, false); + ASSERT_TRUE(e.is_object()); + EXPECT_EQ(e.value("code", std::string{}), "object_unavailable"); + EXPECT_EQ(e.value("origin", std::string{}), "ghost_module"); + + destroyClient(client); +} + +// ── the synchronous twin had the identical hole ───────────────────────────── +TEST_F(CallErrorAfterAcquireTest, SyncSuccessStillReportsTheValue) +{ + LiveHost host; + ASSERT_TRUE(host.ok()); + + lp_client* client = clientFor("slow_module", host.target()); + ASSERT_NE(client, nullptr); + + char* result = nullptr; + char* error = nullptr; + const int rc = lp_invoke(client, "compute", "[]", 5000, &result, &error); + + std::cout << " SYNC success -> rc=" << rc + << " result=" << (result ? result : "(null)") + << " error=" << (error ? error : "(null)") << std::endl; + + EXPECT_EQ(rc, LP_OK); + ASSERT_NE(result, nullptr); + EXPECT_STREQ(result, "7"); + EXPECT_EQ(error, nullptr); + + lp_string_free(result); + lp_string_free(error); + destroyClient(client); +} + +TEST_F(CallErrorAfterAcquireTest, SyncTimeoutReportsTheError) +{ + LiveHost host; + ASSERT_TRUE(host.ok()); + + lp_client* client = clientFor("slow_module", host.target()); + ASSERT_NE(client, nullptr); + + char* result = nullptr; + char* error = nullptr; + const int rc = lp_invoke(client, "slow", "[]", 600, &result, &error); + + std::cout << " SYNC timeout -> rc=" << rc + << " result=" << (result ? result : "(null)") + << " error=" << (error ? error : "(null)") << std::endl; + + EXPECT_EQ(rc, LP_ERR_UNAVAILABLE) << "a timed-out sync call reported success"; + ASSERT_NE(error, nullptr) << "LP_ERR_UNAVAILABLE must carry the error object"; + nlohmann::json e = nlohmann::json::parse(error, nullptr, false); + ASSERT_TRUE(e.is_object()); + EXPECT_EQ(e.value("code", std::string{}), "timeout"); + + lp_string_free(result); + lp_string_free(error); + destroyClient(client); + QElapsedTimer t; t.start(); + while (t.elapsed() < 3500) QCoreApplication::processEvents(QEventLoop::AllEvents, 50); +} + +TEST_F(CallErrorAfterAcquireTest, SyncModuleNotLoadedOnALiveHostReportsTheError) +{ + LiveHost host; + ASSERT_TRUE(host.ok()); + + lp_client* client = clientFor("ghost_module", host.target()); + ASSERT_NE(client, nullptr); + + char* result = nullptr; + char* error = nullptr; + const int rc = lp_invoke(client, "compute", "[]", 5000, &result, &error); + + std::cout << " SYNC not-published -> rc=" << rc + << " result=" << (result ? result : "(null)") + << " error=" << (error ? error : "(null)") << std::endl; + + EXPECT_EQ(rc, LP_ERR_UNAVAILABLE); + ASSERT_NE(error, nullptr); + nlohmann::json e = nlohmann::json::parse(error, nullptr, false); + ASSERT_TRUE(e.is_object()); + EXPECT_EQ(e.value("code", std::string{}), "object_unavailable"); + + lp_string_free(result); + lp_string_free(error); + destroyClient(client); +} + +// ── the residual gap, pinned deliberately ─────────────────────────────────── +// +// An UNKNOWN METHOD is NOT fixed here and cannot be at this layer: every +// provider flavour answers a bare null for a name it doesn't recognise +// (logos-qt-sdk QtProviderObject's `return QVariant()`, the generated Qt and +// cdylib dispatches' `unknown method` fall-through, the Rust provider's), which +// is byte-identical to a method that legitimately returns null. The transport +// sees ok=true with a null value and MUST report success — reporting failure +// would break every method whose return really is null. Closing it needs the +// PROVIDER contract to answer a rejection object for an unknown name, in every +// SDK, and mirrored into lp_invoke so the twins stay identical. +// +// This test asserts today's behaviour so the boundary is explicit rather than +// assumed, and so it fails loudly if a provider ever starts distinguishing. +TEST_F(CallErrorAfterAcquireTest, UnknownMethodStaysIndistinguishableFromANullReturn) +{ + LiveHost host; + ASSERT_TRUE(host.ok()); + + lp_client* client = clientFor("slow_module", host.target()); + ASSERT_NE(client, nullptr); + + Capture c; + ASSERT_EQ(lp_invoke_async(client, "noSuchMethod", "[]", 5000, &captureCb, &c), LP_OK); + ASSERT_TRUE(pumpUntilFired(c, 15000)) << "async callback never fired"; + + std::cout << " ASYNC unknown method -> ok=" << c.ok << " json=" << c.json + << " (residual gap: the provider itself answers a bare null)" + << std::endl; + + EXPECT_EQ(c.ok, 1); + EXPECT_EQ(c.json, "null"); + + destroyClient(client); +} diff --git a/tests/protocol/test_call_error_qt_remote.cpp b/tests/protocol/test_call_error_qt_remote.cpp new file mode 100644 index 0000000..5ef9864 --- /dev/null +++ b/tests/protocol/test_call_error_qt_remote.cpp @@ -0,0 +1,172 @@ +// The same call-error channel, over the DEFAULT transport. +// +// LogosProtocol::LocalSocket (qt_remote / QtRO) is the default in +// LogosTransportConfig, so it is what an in-process module host actually uses. +// test_call_error_after_acquire.cpp proves the channel over plain TCP; if the +// fix stopped there, the transport most calls go over would still be reporting +// a timed-out call as a method that returned null. +// +// The failure exercised here is a DEFERRED ("multi") call whose completion +// event never arrives: RemoteLogosObject answers the pending sentinel, arms its +// bounded wait, and gives up. That branch used to deliver a bare QVariant(); +// it now delivers a "timeout" CallError. Using the deferred path rather than a +// sleeping provider is deliberate — QtRO dispatches the source call on this +// same event loop, so a provider that blocked would stall the very loop the +// consumer needs, and the test would be measuring the harness. +// +// The assertions are made at LogosAPIConsumer::invokeRemoteMethodAsync, which +// is the site that used to hard-code `logos::CallError{}` next to every result. +// lp_invoke_async is a thin renderer over that CallError (proved end-to-end in +// test_call_error_after_acquire.cpp), so pinning it here pins the C ABI too. + +#include + +#include "logos_api_consumer.h" +#include "logos_async_dispatch.h" +#include "logos_instance.h" +#include "logos_object.h" +#include "logos_provider_interface.h" +#include "logos_transport_config.h" +#include "module_proxy.h" +#include "remote_transport.h" +#include "token_manager.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +QCoreApplication* ensureApp() { + static int argc = 0; + static char* argv[] = { nullptr }; + if (!QCoreApplication::instance()) + new QCoreApplication(argc, argv); + return QCoreApplication::instance(); +} + +// compute() answers straight away; stall() answers the pending sentinel and +// then never pushes the completion event, so the consumer's bounded wait is +// the only thing that ends the call. +class StallProvider : public LogosProviderObject { +public: + QVariant callMethod(const QString& method, const QVariantList& /*args*/) override { + if (method == QLatin1String("compute")) return QVariant(7); + if (method == QLatin1String("stall")) { + QVariantMap sentinel; + sentinel[logos::pendingCallKey()] = QStringLiteral("never-completes"); + return sentinel; + } + return QVariant(); + } + bool informModuleToken(const QString&, const QString&) override { return true; } + QJsonArray getMethods() override { return QJsonArray{}; } + void setEventListener(EventCallback) override {} + void init(void*) override {} + QString providerName() const override { return QStringLiteral("qtro_module"); } + QString providerVersion() const override { return QStringLiteral("1.0.0"); } +}; + +} // namespace + +class QtRemoteCallErrorTest : public ::testing::Test { +protected: + void SetUp() override { ensureApp(); } + + void pumpEventLoop(int ms) { + 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)); + } + } +}; + +// ── control: a successful QtRO async call reports its value and NO error ──── +TEST_F(QtRemoteCallErrorTest, AsyncSuccessCarriesAnEmptyError) +{ + const QString registryUrl = LogosInstance::id("qtro_ok_module"); + + RemoteTransportHost host(registryUrl); + StallProvider provider; + ModuleProxy proxy(&provider); + ASSERT_TRUE(proxy.saveToken(QStringLiteral("origin"), QStringLiteral("tok-1"))); + ASSERT_TRUE(host.publishObject("qtro_ok_module", &proxy)); + + LogosAPIConsumer consumer(QStringLiteral("qtro_ok_module"), + QStringLiteral("origin"), + &TokenManager::instance()); + ASSERT_TRUE(consumer.isConnected()); + + std::atomic delivered{0}; + QVariant got; + logos::CallError err; + consumer.invokeRemoteMethodAsync( + QStringLiteral("tok-1"), QStringLiteral("qtro_ok_module"), + QStringLiteral("compute"), QVariantList{}, + [&](QVariant v, const logos::CallError& e) { + got = std::move(v); + err = e; + delivered.fetch_add(1); + }, + Timeout(3000)); + + for (int i = 0; i < 60 && delivered.load() == 0; ++i) pumpEventLoop(50); + + std::cout << " QtRO success -> ok=" << err.ok() + << " value=" << got.toInt() << std::endl; + + ASSERT_EQ(delivered.load(), 1) << "async callback never fired"; + EXPECT_TRUE(err.ok()) << "a successful QtRO call reported error " << err.code; + EXPECT_EQ(got.toInt(), 7); +} + +// ── the deadline elapsed on the default transport ─────────────────────────── +TEST_F(QtRemoteCallErrorTest, AsyncTimeoutCarriesTheCanonicalError) +{ + const QString registryUrl = LogosInstance::id("qtro_stall_module"); + + RemoteTransportHost host(registryUrl); + StallProvider provider; + ModuleProxy proxy(&provider); + ASSERT_TRUE(proxy.saveToken(QStringLiteral("origin"), QStringLiteral("tok-1"))); + ASSERT_TRUE(host.publishObject("qtro_stall_module", &proxy)); + + LogosAPIConsumer consumer(QStringLiteral("qtro_stall_module"), + QStringLiteral("origin"), + &TokenManager::instance()); + ASSERT_TRUE(consumer.isConnected()); + + std::atomic delivered{0}; + QVariant got; + logos::CallError err; + consumer.invokeRemoteMethodAsync( + QStringLiteral("tok-1"), QStringLiteral("qtro_stall_module"), + QStringLiteral("stall"), QVariantList{}, + [&](QVariant v, const logos::CallError& e) { + got = std::move(v); + err = e; + delivered.fetch_add(1); + }, + Timeout(400)); + + for (int i = 0; i < 100 && delivered.load() == 0; ++i) pumpEventLoop(50); + + std::cout << " QtRO timeout -> code='" << err.code + << "' origin='" << err.origin + << "' message='" << err.message << "'" << std::endl; + + ASSERT_EQ(delivered.load(), 1) << "async callback never fired"; + EXPECT_EQ(err.code, "timeout") << "a timed-out QtRO call reported success"; + EXPECT_EQ(err.origin, "qtro_stall_module"); + EXPECT_FALSE(err.message.empty()); + EXPECT_FALSE(got.isValid()); +}