diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..292aa96 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +# pull_request is unfiltered: stacked PRs (based on other feature branches) +# must run CI too. +on: + push: + branches: [master, main] + pull_request: + workflow_dispatch: + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + # DeterminateSystems' installer: reliable on the macOS runners, where + # cachix/install-nix-action trips over pre-existing nix build users + # (eDSRecordAlreadyExists). + - uses: DeterminateSystems/nix-installer-action@main + + - uses: cachix/cachix-action@v15 + # The cache is an optimization: don't let a failed push in the + # action's post step fail a job whose tests all passed. + continue-on-error: true + with: + name: logos-co + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + + - name: Build tests + run: nix build '.#tests' + + - name: Run protocol tests + run: ./result/bin/protocol_tests + env: + QT_QPA_PLATFORM: offscreen diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 0b2f2a2..b393719 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -20,6 +20,8 @@ find_package(nlohmann_json REQUIRED) set(PROTOCOL_SOURCES logos_protocol.h + logos_module_impl.h + logos_call_error.h logos_protocol.cpp logos_types.cpp logos_types.h @@ -159,6 +161,8 @@ install(FILES # (etc.) keep resolving unchanged through propagated include dirs. install(FILES logos_protocol.h + logos_module_impl.h + logos_call_error.h logos_types.h logos_api_client.h logos_api_consumer.h diff --git a/cpp/logos_api_client.cpp b/cpp/logos_api_client.cpp index 409d9b9..55d2df7 100644 --- a/cpp/logos_api_client.cpp +++ b/cpp/logos_api_client.cpp @@ -83,6 +83,13 @@ bool LogosAPIClient::reconnect() QVariant LogosAPIClient::invokeRemoteMethod(const QString& objectName, const QString& methodName, const QVariantList& args, Timeout timeout) { + return invokeRemoteMethod(objectName, methodName, args, timeout, nullptr); +} + +QVariant LogosAPIClient::invokeRemoteMethod(const QString& objectName, const QString& methodName, + const QVariantList& args, Timeout timeout, logos::CallError* err) +{ + if (err) err->clear(); // Marshal the whole operation (capability/token fetch + the call) onto the // owner thread so a worker thread (e.g. an HTTP handler) can call other // modules. Same-thread callers run directly. See logos_thread_marshal.h. @@ -101,7 +108,7 @@ QVariant LogosAPIClient::invokeRemoteMethod(const QString& objectName, const QSt qDebug() << "LogosAPIClient: requestModule result for" << objectName << ":" << token; } - return m_consumer->invokeRemoteMethod(token, objectName, methodName, args, timeout); + return m_consumer->invokeRemoteMethod(token, objectName, methodName, args, timeout, err); }); } diff --git a/cpp/logos_api_client.h b/cpp/logos_api_client.h index 52a6911..eeefa7d 100644 --- a/cpp/logos_api_client.h +++ b/cpp/logos_api_client.h @@ -9,6 +9,7 @@ #include #include +#include "logos_call_error.h" #include "logos_mode.h" #include "logos_transport_config.h" #include @@ -70,10 +71,23 @@ public: QString registryUrl() const; bool reconnect(); - QVariant invokeRemoteMethod(const QString& objectName, const QString& methodName, + QVariant invokeRemoteMethod(const QString& objectName, const QString& methodName, const QVariantList& args = QVariantList(), Timeout timeout = Timeout()); - QVariant invokeRemoteMethod(const QString& objectName, const QString& methodName, + /** + * @brief invokeRemoteMethod with an explicit error out-channel. + * + * Fills *err with the canonical {code, message, origin} call error when + * the failure is detectable (today: "object_unavailable" when the target + * object cannot be acquired); cleared on success. Generated typed client + * wrappers call this overload and throw logos::LogosCallError so callers + * can distinguish a failed call from a legitimately default-valued + * result. + */ + QVariant invokeRemoteMethod(const QString& objectName, const QString& methodName, + const QVariantList& args, Timeout timeout, logos::CallError* err); + + QVariant invokeRemoteMethod(const QString& objectName, const QString& methodName, const QVariant& arg, Timeout timeout = Timeout()); QVariant invokeRemoteMethod(const QString& objectName, const QString& methodName, diff --git a/cpp/logos_api_consumer.cpp b/cpp/logos_api_consumer.cpp index e2fb687..f46be66 100644 --- a/cpp/logos_api_consumer.cpp +++ b/cpp/logos_api_consumer.cpp @@ -105,11 +105,25 @@ bool LogosAPIConsumer::reconnect() QVariant LogosAPIConsumer::invokeRemoteMethod(const QString& authToken, const QString& objectName, const QString& methodName, const QVariantList& args, Timeout timeout) { + return invokeRemoteMethod(authToken, objectName, methodName, args, timeout, nullptr); +} + +QVariant LogosAPIConsumer::invokeRemoteMethod(const QString& authToken, const QString& objectName, const QString& methodName, + const QVariantList& args, Timeout timeout, logos::CallError* err) +{ + if (err) err->clear(); qDebug() << "LogosAPIConsumer: Calling invokeRemoteMethod:" << objectName << methodName << "args_count:" << args.size() << "timeout:" << timeout.ms; LogosObject* plugin = m_transport->requestObject(objectName, timeout.ms); if (!plugin) { qWarning() << "LogosAPIConsumer: Failed to acquire plugin/replica for object:" << objectName; + if (err) { + err->code = "object_unavailable"; + err->message = "failed to acquire remote object '" + + objectName.toStdString() + + "' (module not loaded, not published, or transport failure)"; + err->origin = objectName.toStdString(); + } return QVariant(); } diff --git a/cpp/logos_api_consumer.h b/cpp/logos_api_consumer.h index 267b207..2157d64 100644 --- a/cpp/logos_api_consumer.h +++ b/cpp/logos_api_consumer.h @@ -11,6 +11,7 @@ #include #include +#include "logos_call_error.h" #include "logos_mode.h" #include "logos_transport_config.h" @@ -79,6 +80,19 @@ public: QVariant invokeRemoteMethod(const QString& authToken, const QString& objectName, const QString& methodName, const QVariantList& args = QVariantList(), Timeout timeout = Timeout()); + /** + * @brief invokeRemoteMethod with an explicit error out-channel. + * + * Fills *err with the canonical {code, message, origin} call error when + * the failure is detectable on this side (today: "object_unavailable" + * when the target object/replica cannot be acquired). On success *err is + * cleared. Failures the transport cannot yet distinguish from a void + * result (per-dispatch errors) leave *err clear — the struct is the + * extension point for surfacing transport-level statuses later. + */ + QVariant invokeRemoteMethod(const QString& authToken, const QString& objectName, const QString& methodName, + const QVariantList& args, Timeout timeout, logos::CallError* err); + using AsyncResultCallback = std::function; /** diff --git a/cpp/logos_call_error.h b/cpp/logos_call_error.h new file mode 100644 index 0000000..34e6564 --- /dev/null +++ b/cpp/logos_call_error.h @@ -0,0 +1,32 @@ +#ifndef LOGOS_CALL_ERROR_H +#define LOGOS_CALL_ERROR_H + +// The canonical cross-module call error — the C++ face of the protocol's +// {code, message, origin} error JSON (see makeErrorJson / lp_invoke's +// out_error_json). Deliberately Qt-free: it crosses into Qt-free module +// code (generated typed wrappers expose it as an optional out-parameter, +// e.g. `calc.add(a, b, &err)`), so only std types appear here. + +#include + +namespace logos { + +// Error codes are lowercase snake_case strings, mirroring the C ABI's JSON +// contract rather than an enum so the set can grow (transport-level codes, +// 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). +struct CallError { + std::string code; // empty = no error + std::string message; + std::string origin; // module the error originated from / was detected for + + bool ok() const { return code.empty(); } + void clear() { code.clear(); message.clear(); origin.clear(); } +}; + +} // namespace logos + +#endif // LOGOS_CALL_ERROR_H diff --git a/cpp/logos_json_convert.cpp b/cpp/logos_json_convert.cpp index 0e5bebc..54aa457 100644 --- a/cpp/logos_json_convert.cpp +++ b/cpp/logos_json_convert.cpp @@ -82,6 +82,17 @@ nlohmann::json qvariantToNlohmann(const QVariant& v) catch (...) {} } + // Integers stay integers: QJsonValue::fromVariant degrades every numeric + // to double, which a strict consumer on the other side of the C ABI + // (e.g. a generated dispatch reading an int param) must not see as 5.0. + switch (v.userType()) { + case QMetaType::Int: return v.toInt(); + case QMetaType::UInt: return v.toUInt(); + case QMetaType::LongLong: return static_cast(v.toLongLong()); + case QMetaType::ULongLong: return static_cast(v.toULongLong()); + default: break; + } + QJsonValue jv = QJsonValue::fromVariant(v); if (jv.isString()) return jv.toString().toStdString(); if (jv.isBool()) return jv.toBool(); diff --git a/cpp/logos_module_impl.h b/cpp/logos_module_impl.h new file mode 100644 index 0000000..d3b4b30 --- /dev/null +++ b/cpp/logos_module_impl.h @@ -0,0 +1,100 @@ +#ifndef LOGOS_MODULE_IMPL_H +#define LOGOS_MODULE_IMPL_H + +/* =========================================================================== + * logos_module_impl.h — the COMMON module-impl C ABI. + * + * ONE contract for module implementations in every language: a Logos module + * compiles to a cdylib exporting exactly these symbols. The C++ SDK emits + * this wrapper around a universal C++ impl class; the Rust SDK emits it + * around a Rust impl. The uniform generated Qt-plugin glue (and, later, a + * no-Qt host) talks to the cdylib ONLY through this ABI — the glue is + * identical regardless of the module's source language, which is what makes + * the eventual Qt-glue removal a host swap instead of a per-language change. + * + * Data model mirrors the lp_* consumer ABI (logos_protocol.h): + * - method args / event payloads: JSON array (UTF-8 const char*) + * - results: JSON value + * - bytes: the canonical {"_bytes":""} tagged form + * - errors from dispatch: NULL return, or a canonical error object + * {"code","message","origin"} returned as the result of a failed call + * when the implementation prefers structured errors. + * + * Ownership: every char* RETURNED by the module is heap-allocated and the + * CALLER frees it with logos_module_string_free (exported by the module so + * allocator domains never mix). Every const char* passed IN is borrowed. + * + * Threading: the host serializes dispatch calls (one at a time) unless a + * future capability negotiates otherwise. The emit callback may be invoked + * from any module thread; the host marshals. + * + * Versioning: logos_module_get_protocol_version() returns the + * logos-protocol semver the module was COMPILED against (forwarded from + * LOGOS_PROTOCOL_VERSION_STRING, never minted). Hosts apply the same rule + * as the metadata stamp: equal MAJOR ⇔ compatible. This runtime handshake + * complements the build-time metadata stamp and is what a no-Qt host (no + * Qt plugin metadata) negotiates with. + * =========================================================================== */ + +#ifdef __cplusplus +extern "C" { +#endif + +#if defined(_WIN32) +#define LOGOS_MODULE_IMPL_EXPORT __declspec(dllexport) +#else +#define LOGOS_MODULE_IMPL_EXPORT __attribute__((visibility("default"))) +#endif + +/* Event-emission callback installed by the host/glue. `data_json` is a JSON + * array payload, borrowed for the duration of the call. */ +typedef void (*logos_module_emit_cb)(const char* event_name, + const char* data_json, + void* user_data); + +/* --------------------------------------------------------------------------- + * Exported by every module cdylib (generated by the SDK of the module's + * language; module authors never write these by hand). + * ------------------------------------------------------------------------- */ + +/* Dispatch a method call. Returns the result JSON value as a heap string + * (free with logos_module_string_free), or NULL when the method is unknown + * or dispatch failed structurally. */ +LOGOS_MODULE_IMPL_EXPORT char* logos_module_dispatch(const char* method, + const char* args_json); + +/* The module's method/event metadata as a JSON array — same shape as + * LogosProviderObject::getMethods() (entries tagged "method"/"event"). */ +LOGOS_MODULE_IMPL_EXPORT char* logos_module_get_methods(void); + +/* Module identity/context, stamped by the host before the first dispatch: + * module path, instance id, per-instance persistence path. Mirrors + * LogosModuleContext / RustModuleContext. Any argument may be NULL. */ +LOGOS_MODULE_IMPL_EXPORT void logos_module_set_context( + const char* module_path, + const char* instance_id, + const char* instance_persistence_path); + +/* Install the host's event-emission callback. The module keeps (cb, + * user_data) and invokes cb once per emitted event. Passing NULL clears it; + * after the clearing call returns, the module must not invoke the old cb. */ +LOGOS_MODULE_IMPL_EXPORT void logos_module_set_emit_callback( + logos_module_emit_cb cb, void* user_data); + +/* Deliver an auth token for `module_name` (the provider-side + * informModuleToken). Returns 0 on acceptance. */ +LOGOS_MODULE_IMPL_EXPORT int logos_module_accept_token(const char* module_name, + const char* token); + +/* The logos-protocol semver this module was compiled against. Static + * string — do NOT free. */ +LOGOS_MODULE_IMPL_EXPORT const char* logos_module_get_protocol_version(void); + +/* Free a string returned by this module. Safe on NULL. */ +LOGOS_MODULE_IMPL_EXPORT void logos_module_string_free(char* s); + +#ifdef __cplusplus +} +#endif + +#endif /* LOGOS_MODULE_IMPL_H */ diff --git a/cpp/logos_protocol.cpp b/cpp/logos_protocol.cpp index 8eba781..b3a3e76 100644 --- a/cpp/logos_protocol.cpp +++ b/cpp/logos_protocol.cpp @@ -242,8 +242,16 @@ int lp_invoke(lp_client* client, return LP_ERR_INVALID_ARG; } + logos::CallError callErr; const QVariant result = client->client->invokeRemoteMethod( - client->target, QString::fromUtf8(method), args, lpTimeout(timeout_ms)); + client->target, QString::fromUtf8(method), args, lpTimeout(timeout_ms), &callErr); + + if (!callErr.ok()) { + if (out_error_json) + *out_error_json = lpStrdup(makeErrorJson( + callErr.code.c_str(), callErr.message, callErr.origin)); + return LP_ERR_UNAVAILABLE; + } if (out_result_json) *out_result_json = lpStrdup(logos::qvariantToNlohmann(result).dump()); diff --git a/cpp/logos_protocol.h b/cpp/logos_protocol.h index ec68d85..bb0a271 100644 --- a/cpp/logos_protocol.h +++ b/cpp/logos_protocol.h @@ -65,6 +65,7 @@ extern "C" { #define LP_ERR_INVALID_ARG (-1) #define LP_ERR_UNSUPPORTED (-2) /* provider surface: exercised in a later phase */ #define LP_ERR_INTERNAL (-3) +#define LP_ERR_UNAVAILABLE (-4) /* target module/object could not be acquired */ /* --------------------------------------------------------------------------- * Version diff --git a/cpp/logos_provider_interface.h b/cpp/logos_provider_interface.h index 2fb41be..64064c9 100644 --- a/cpp/logos_provider_interface.h +++ b/cpp/logos_provider_interface.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -66,4 +67,22 @@ protected: void setEventListenerStdBridge(EventCallback callback); }; +// --------------------------------------------------------------------------- +// LogosProviderPlugin — Qt interface for plugin loading (framework internal) +// +// New-API plugins implement this so hosts and tools can detect them via +// qobject_cast() and use createProviderObject(). +// Lives here (beside the abstract LogosProviderObject) so plugin-loading +// tools need only the protocol headers; the developer-facing base classes +// remain in logos-qt-sdk. +// --------------------------------------------------------------------------- +class LogosProviderPlugin { +public: + virtual ~LogosProviderPlugin() = default; + virtual LogosProviderObject* createProviderObject() = 0; +}; + +#define LogosProviderPlugin_iid "org.logos.LogosProviderPlugin" +Q_DECLARE_INTERFACE(LogosProviderPlugin, LogosProviderPlugin_iid) + #endif // LOGOS_PROVIDER_INTERFACE_H diff --git a/tests/protocol/CMakeLists.txt b/tests/protocol/CMakeLists.txt index 795cdca..61b1e50 100644 --- a/tests/protocol/CMakeLists.txt +++ b/tests/protocol/CMakeLists.txt @@ -6,6 +6,7 @@ add_executable(protocol_tests test_protocol_version.cpp test_json_convert_bytes.cpp test_lp_client.cpp + test_call_error.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.cpp b/tests/protocol/test_call_error.cpp new file mode 100644 index 0000000..b403920 --- /dev/null +++ b/tests/protocol/test_call_error.cpp @@ -0,0 +1,42 @@ +#include + +#include "logos_protocol.h" + +#include + +// The call-error channel: invoking a method on a target whose object cannot +// be acquired must fail loudly — LP_ERR_UNAVAILABLE plus the canonical +// {code, message, origin} error JSON — never LP_OK with a null result (the +// silent-default trap generated typed wrappers fell into; see +// logos_call_error.h). +TEST(CallErrorChannel, UnreachableTargetYieldsCanonicalError) +{ + // Plain TCP to a port nothing listens on: connection refused, fast, + // no daemon or event loop required. + const char* deadTarget = + "{\"protocol\":\"tcp\",\"host\":\"127.0.0.1\",\"port\":9}"; + + // Pre-save a token so the capability requestModule flow is skipped — + // this test exercises the transport-acquisition failure only. + ASSERT_EQ(lp_token_save("missing_module", "test-token"), LP_OK); + + lp_client* client = + lp_client_create("missing_module", "origin", deadTarget, deadTarget); + ASSERT_NE(client, nullptr); + + char* result = nullptr; + char* error = nullptr; + const int rc = lp_invoke(client, "anyMethod", "[1,2]", 1500, &result, &error); + EXPECT_EQ(rc, LP_ERR_UNAVAILABLE); + EXPECT_EQ(result, nullptr); + 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"); + EXPECT_EQ(e.value("origin", std::string{}), "missing_module"); + EXPECT_FALSE(e.value("message", std::string{}).empty()); + + lp_string_free(error); + lp_client_destroy(client); +} diff --git a/tests/protocol/test_json_convert_bytes.cpp b/tests/protocol/test_json_convert_bytes.cpp index 5688266..fe09435 100644 --- a/tests/protocol/test_json_convert_bytes.cpp +++ b/tests/protocol/test_json_convert_bytes.cpp @@ -112,3 +112,25 @@ TEST(JsonConvertBytes, OrdinaryObjectsAreNotMistakenForBytes) QVariant v3 = nlohmannToQVariant(plain); EXPECT_NE(v3.userType(), QMetaType::QByteArray); } + +TEST(JsonConvertBytes, IntegersStayIntegersNotDoubles) +{ + // QJsonValue::fromVariant degrades every numeric to double; the canonical + // C-ABI converter must not — a strict consumer (e.g. a generated dispatch + // reading an int param) rejects 5.0 where it expects 5. + nlohmann::json a = qvariantToNlohmann(QVariant(static_cast(5))); + EXPECT_TRUE(a.is_number_integer() || a.is_number_unsigned()); + EXPECT_EQ(a.get(), 5); + + nlohmann::json b = qvariantToNlohmann(QVariant(static_cast(-7))); + EXPECT_TRUE(b.is_number_integer()); + EXPECT_EQ(b.get(), -7); + + nlohmann::json c = qvariantToNlohmann(QVariant(42)); + EXPECT_TRUE(c.is_number_integer()); + EXPECT_EQ(c.get(), 42); + + // Doubles stay doubles. + nlohmann::json d = qvariantToNlohmann(QVariant(3.5)); + EXPECT_TRUE(d.is_number_float()); +}