diff --git a/.github/workflows/doctests.yml b/.github/workflows/doctests.yml index 04528bf..5aa23a7 100644 --- a/.github/workflows/doctests.yml +++ b/.github/workflows/doctests.yml @@ -107,6 +107,7 @@ jobs: nix run github:logos-co/logos-doctest -- run \ doctests/cpp-sdk-module-runtime.test.yaml \ doctests/cpp-sdk-module-composition.test.yaml \ + doctests/cpp-sdk-worker-thread-http.test.yaml \ --verbose \ --continue-on-fail \ --release-for logos-cpp-sdk=${{ steps.commit.outputs.sha }} \ @@ -134,7 +135,7 @@ jobs: - name: Verify markdown generation run: | - for spec in cpp-sdk-module-runtime cpp-sdk-module-composition; do + for spec in cpp-sdk-module-runtime cpp-sdk-module-composition cpp-sdk-worker-thread-http; do nix run github:logos-co/logos-doctest -- generate \ "doctests/$spec.test.yaml" \ --release-for logos-cpp-sdk=${{ steps.commit.outputs.sha }} \ diff --git a/cpp/logos_api.cpp b/cpp/logos_api.cpp index 32ec8b3..0daedd0 100644 --- a/cpp/logos_api.cpp +++ b/cpp/logos_api.cpp @@ -1,6 +1,7 @@ #include "logos_api.h" #include "logos_api_client.h" #include "logos_api_provider.h" +#include "logos_thread_marshal.h" #include "token_manager.h" #include #include @@ -58,6 +59,14 @@ LogosAPIClient* LogosAPI::getClient(const std::string& target_module) const LogosAPIClient* LogosAPI::getClient(const QString& target_module, const LogosTransportConfig& transport) const { + // Create the client (and its consumers + transport replicas) on this + // LogosAPI's owner thread — the module's main/event-loop thread — even when + // called from a worker thread (e.g. an HTTP handler). Qt Remote Objects + // replicas only work on the thread that created them, so construction (and + // the cache it populates) must happen there. invokeRemoteMethod() then + // marshals calls back to the same thread. See logos_thread_marshal.h. + return logos::runOnOwnerThread(const_cast(this), + [&]() -> LogosAPIClient* { // Single cache, single construction path. Key composition mirrors // the factory's resolution rule (see LogosAPIClientCacheKey in // logos_api.h): @@ -90,6 +99,7 @@ LogosAPIClient* LogosAPI::getClient(const QString& target_module, const_cast(this)); m_clients.insert(key, client); return client; + }); } TokenManager* LogosAPI::getTokenManager() const diff --git a/cpp/logos_api_client.cpp b/cpp/logos_api_client.cpp index 5719d0d..d3db4d7 100644 --- a/cpp/logos_api_client.cpp +++ b/cpp/logos_api_client.cpp @@ -4,6 +4,7 @@ #include "logos_object.h" #include "logos_types.h" #include "logos_json_convert.h" +#include "logos_thread_marshal.h" #include "token_manager.h" #include #include @@ -59,7 +60,10 @@ LogosAPIClient::~LogosAPIClient() LogosObject* LogosAPIClient::requestObject(const QString& objectName, Timeout timeout) { - return m_consumer->requestObject(objectName, timeout); + // Marshal to the owner thread: the replica is acquired and lives there. + return logos::runOnOwnerThread(this, [&]() -> LogosObject* { + return m_consumer->requestObject(objectName, timeout); + }); } bool LogosAPIClient::isConnected() const @@ -80,6 +84,10 @@ bool LogosAPIClient::reconnect() QVariant LogosAPIClient::invokeRemoteMethod(const QString& objectName, const QString& methodName, const QVariantList& args, Timeout timeout) { + // 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. + return logos::runOnOwnerThread(this, [&]() -> QVariant { qDebug() << "LogosAPIClient: invoking remote method" << objectName << methodName << "args_count:" << args.size(); QString token = getToken(objectName); @@ -95,6 +103,7 @@ QVariant LogosAPIClient::invokeRemoteMethod(const QString& objectName, const QSt } return m_consumer->invokeRemoteMethod(token, objectName, methodName, args, timeout); + }); } QVariant LogosAPIClient::invokeRemoteMethod(const QString& objectName, const QString& methodName, @@ -135,6 +144,21 @@ void LogosAPIClient::invokeRemoteMethodAsync(const QString& objectName, const QS { if (!callback) return; + // The async path acquires a replica too, so it must also run on the owner + // thread. Unlike the sync path we post non-blocking (QueuedConnection): the + // worker caller returns immediately and the result callback fires on the + // owner thread when the reply arrives. + if (QThread::currentThread() != this->thread()) { + QMetaObject::invokeMethod(this, + [this, objectName, methodName, args, + callback = std::move(callback), timeout]() mutable { + invokeRemoteMethodAsync(objectName, methodName, args, + std::move(callback), timeout); + }, + Qt::QueuedConnection); + return; + } + QString token = getToken(objectName); if (token.isEmpty() && objectName != "capability_module" && m_capability_consumer) { @@ -222,7 +246,10 @@ void LogosAPIClient::invokeRemoteMethodAsync(const QString& objectName, const QS void LogosAPIClient::onEvent(LogosObject* originObject, const QString& eventName, std::function callback) { - m_consumer->onEvent(originObject, eventName, std::move(callback)); + // Marshal to the owner thread: event registration touches the replica. + logos::runOnOwnerThread(this, [&]() { + m_consumer->onEvent(originObject, eventName, std::move(callback)); + }); } void LogosAPIClient::onEventResponse(LogosObject* object, const QString& eventName, const QVariantList& data) diff --git a/cpp/logos_thread_marshal.h b/cpp/logos_thread_marshal.h new file mode 100644 index 0000000..55a5f18 --- /dev/null +++ b/cpp/logos_thread_marshal.h @@ -0,0 +1,51 @@ +#ifndef LOGOS_THREAD_MARSHAL_H +#define LOGOS_THREAD_MARSHAL_H + +#include + +#include +#include +#include + +namespace logos { + +// Run `fn` on `obj`'s (owner) thread, blocking the caller until it completes, +// and forward the return value. If already on that thread, runs directly with +// no marshaling and no overhead (the common case). +// +// Why: Logos inter-module calls go over Qt Remote Objects, whose replicas only +// work on the thread that owns them (the module's main/event-loop thread). This +// lets a module call other modules from a worker thread (e.g. an HTTP server +// thread) without the module touching Qt — the SDK transparently marshals the +// call onto the owner thread. +// +// Requirements: +// - `obj`'s thread must be running an event loop (it is — the module's main +// thread runs QCoreApplication::exec()). The same-thread guard avoids the +// BlockingQueuedConnection self-deadlock. +// - The return type must be void or default-constructible (the marshaled +// branch holds the result in a local before assigning it), and must not be +// a reference (there'd be nothing to bind the local to). Both are satisfied +// by the SDK's uses here (void, QVariant, LogosObject*, LogosAPIClient*). +template +auto runOnOwnerThread(QObject* obj, Fn&& fn) -> decltype(fn()) +{ + using Ret = decltype(fn()); + static_assert(!std::is_reference_v, + "runOnOwnerThread does not support reference return types"); + if (QThread::currentThread() == obj->thread()) { + return fn(); + } + if constexpr (std::is_void_v) { + QMetaObject::invokeMethod(obj, [&]() { fn(); }, Qt::BlockingQueuedConnection); + return; + } else { + Ret ret{}; + QMetaObject::invokeMethod(obj, [&]() { ret = fn(); }, Qt::BlockingQueuedConnection); + return ret; + } +} + +} // namespace logos + +#endif // LOGOS_THREAD_MARSHAL_H diff --git a/doctests/cpp-sdk-worker-thread-http.test.yaml b/doctests/cpp-sdk-worker-thread-http.test.yaml new file mode 100644 index 0000000..7b7e3e1 --- /dev/null +++ b/doctests/cpp-sdk-worker-thread-http.test.yaml @@ -0,0 +1,578 @@ +name: "Calling a Module From a Worker Thread (HTTP Server)" +output: cpp-sdk-worker-thread-http.md +release: "" + +intro: | + Logos inter-module calls travel over Qt Remote Objects, whose replicas only + work on the thread that owns them — the module's main/event-loop thread. So a + module that wants to call **another** module from a *worker* thread has a + problem: the call would otherwise run on the worker thread and hang on replica + acquisition (there's no event loop there to drive it). + + That worker-thread case is not exotic — it's exactly what you hit the moment a + module embeds a server. The motivating example is a module that serves an HTTP + `/metrics` endpoint and, on each scrape, calls other modules to gather their + numbers. The HTTP server runs on its own thread; the inter-module calls happen + there. + + This doc-test proves that path works on the SDK commit under test, and that the + module stays **pure C++** — it never touches Qt to make it work. The SDK does + the marshaling: `LogosAPIClient` transparently runs `getClient` / + `invokeRemoteMethod` / `requestObject` / `onEvent` on the module's owner thread + when they're called from another thread. + + It is fully self-contained: + + 1. Create `sensor_module`, a tiny **callee** with one method, `readTemperature()`. + 2. Create `http_module`, a **caller** that depends on `sensor_module`, embeds a + [libmicrohttpd](https://www.gnu.org/software/libmicrohttpd/) server, and on + every HTTP request calls `sensor_module.readTemperature()` **from the server + thread** through the generated `modules().sensor_module` wrapper. + 3. Build **both** against the C++ SDK commit under test, run them in + `logoscore`, start the server, and `curl` it. + + A green run means the `curl` got the sensor's reading back — i.e. the + worker-thread inter-module call completed instead of hanging. Without the SDK's + thread marshaling it would deadlock on the server thread and the `curl` would + time out. + +what_you_build: "Two modules — a `sensor_module` callee and an `http_module` caller that embeds an HTTP server — built against this SDK commit and run in `logoscore`, where an HTTP request drives a cross-module call from the server's worker thread." + +what_you_learn: + - Why inter-module calls must run on the module's owner thread (Qt Remote Objects replica affinity) + - How the SDK lets a module call another module from a worker thread without touching Qt + - How to embed a third-party C library (libmicrohttpd, via pkg-config) in a universal module + - How to drive a cross-module call from an HTTP handler and scrape it with `curl` + +prerequisites: + - | + **Nix** with flakes enabled. Install from [nixos.org](https://nixos.org/download.html), then enable flakes: + + ```bash + mkdir -p ~/.config/nix + echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf + ``` + + Verify: `nix flake --help >/dev/null 2>&1 && echo "Flakes enabled"` + - "**git** — nix flakes only see files tracked by git." + - "**curl** — to scrape the endpoint." + - "A Linux or macOS machine." + +sections: + - title: "Create the callee: sensor_module" + step: true + text: | + `sensor_module` is an ordinary `core` module in the pure-C++ (`interface: + universal`) style with a single method. `http_module` will call it on every + HTTP request. + steps: + - title: "metadata.json" + text: "No dependencies; `interface: universal` selects the pure-C++ pattern." + file: + path: sensor_module/metadata.json + language: json + content: | + { + "name": "sensor_module", + "version": "1.0.0", + "type": "core", + "category": "general", + "description": "A callee module: returns a temperature reading", + "main": "sensor_module_plugin", + "interface": "universal", + "dependencies": [], + + "nix": { + "packages": { + "build": [], + "runtime": [] + }, + "external_libraries": [], + "cmake": { + "find_packages": [], + "extra_sources": [] + } + } + } + + - title: "CMakeLists.txt" + file: + path: sensor_module/CMakeLists.txt + language: cmake + content: | + cmake_minimum_required(VERSION 3.14) + project(SensorModulePlugin LANGUAGES CXX) + + if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT}) + include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake) + elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake") + include(cmake/LogosModule.cmake) + else() + message(FATAL_ERROR "LogosModule.cmake not found") + endif() + + logos_module( + NAME sensor_module + SOURCES + src/sensor_module_impl.h + src/sensor_module_impl.cpp + ) + + - title: "flake.nix" + file: + path: sensor_module/flake.nix + language: nix + content: | + { + description = "Sensor core module - a callee for the worker-thread doc-test"; + + inputs = { + logos-module-builder.url = "github:logos-co/logos-module-builder{release}"; + }; + + outputs = inputs@{ logos-module-builder, ... }: + logos-module-builder.lib.mkLogosModule { + src = ./.; + configFile = ./metadata.json; + flakeInputs = inputs; + }; + } + + - title: "src/sensor_module_impl.h — the class" + text: "A plain C++ class — no base, no Qt. Its one public method becomes callable over IPC." + file: + path: sensor_module/src/sensor_module_impl.h + language: cpp + content: | + #pragma once + + #include + + // A trivial sensor. http_module calls readTemperature() on every HTTP + // request, from its server thread. + class SensorModuleImpl { + public: + /// Returns the current temperature reading (degrees Celsius). + int64_t readTemperature(); + }; + + - title: "src/sensor_module_impl.cpp — the implementation" + file: + path: sensor_module/src/sensor_module_impl.cpp + language: cpp + content: | + #include "sensor_module_impl.h" + + int64_t SensorModuleImpl::readTemperature() + { + return 42; + } + + - title: "Create the caller: http_module" + step: true + text: | + `http_module` declares `sensor_module` as a dependency (so the builder + generates a typed `modules().sensor_module` wrapper) and embeds an HTTP + server using **libmicrohttpd**. The server runs on its own thread; its + request handler calls `sensor_module.readTemperature()` from there. The + module code never mentions Qt — the SDK marshals the cross-module call onto + the module's owner thread. + steps: + - title: "metadata.json — declare the dependency and the C library" + text: | + `dependencies` lists `sensor_module`. `nix.packages.runtime` adds + `libmicrohttpd` (a build input, so the plugin can link it) and + `nix.packages.build` adds `pkg-config` so CMake can find it. + file: + path: http_module/metadata.json + language: json + content: | + { + "name": "http_module", + "version": "1.0.0", + "type": "core", + "category": "general", + "description": "A caller module: serves HTTP and reads sensor_module from the server thread", + "main": "http_module_plugin", + "interface": "universal", + "dependencies": ["sensor_module"], + + "nix": { + "packages": { + "build": ["pkg-config"], + "runtime": ["libmicrohttpd"] + }, + "external_libraries": [], + "cmake": { + "find_packages": [], + "extra_sources": [] + } + } + } + + - title: "CMakeLists.txt — link libmicrohttpd" + text: "After `logos_module(...)`, find libmicrohttpd via pkg-config and link it into the generated plugin target (`_module_plugin`)." + file: + path: http_module/CMakeLists.txt + language: cmake + content: | + cmake_minimum_required(VERSION 3.14) + project(HttpModulePlugin LANGUAGES CXX) + + if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT}) + include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake) + elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake") + include(cmake/LogosModule.cmake) + else() + message(FATAL_ERROR "LogosModule.cmake not found") + endif() + + logos_module( + NAME http_module + SOURCES + src/http_module_impl.h + src/http_module_impl.cpp + ) + + find_package(PkgConfig REQUIRED) + pkg_check_modules(MHD REQUIRED IMPORTED_TARGET libmicrohttpd) + target_link_libraries(http_module_module_plugin PRIVATE PkgConfig::MHD) + + - title: "flake.nix — add the dependency input" + text: | + Declare `sensor_module` as a flake input (the input name **must match** + the dependency name). The `path:` value is a placeholder — we lock it to + the real sensor checkout in the build step. + file: + path: http_module/flake.nix + language: nix + content: | + { + description = "HTTP core module - reads sensor_module from its server thread"; + + inputs = { + logos-module-builder.url = "github:logos-co/logos-module-builder{release}"; + + # The module this one depends on. Placeholder path — locked to the + # real checkout in the build step via --override-input. + sensor_module.url = "path:/path/to/your/sensor_module"; + }; + + outputs = inputs@{ logos-module-builder, sensor_module, ... }: + logos-module-builder.lib.mkLogosModule { + src = ./.; + configFile = ./metadata.json; + flakeInputs = inputs; + }; + } + + - title: "src/http_module_impl.h — the class" + text: | + Pure C++: `LogosModuleContext` (for `modules()`), a `std::mutex`, and an + opaque `void* m_daemon` (the libmicrohttpd handle stays out of the header + the generator parses). `start`/`stop` control the server; `readSensor` + does the cross-module call and is what the HTTP handler invokes. + file: + path: http_module/src/http_module_impl.h + language: cpp + content: | + #pragma once + + #include + #include + + #include // LogosModuleContext base + modules() + + // Serves HTTP via libmicrohttpd. On each request the server thread calls + // sensor_module through modules().sensor_module — the SDK marshals that + // call onto this module's owner thread. No Qt here. + class HttpModuleImpl : public LogosModuleContext { + public: + HttpModuleImpl() = default; + ~HttpModuleImpl(); + + /// Start the HTTP server on `port`. Returns 1 on success, 0 on + /// failure (already running / bad port / bind error). + int64_t start(int64_t port); + + /// Stop the HTTP server. Returns 1 if it was running, 0 otherwise. + int64_t stop(); + + /// Read sensor_module.readTemperature(). The HTTP handler calls this + /// from the server (worker) thread; exposed as a method so it can + /// also be driven directly for comparison. + int64_t readSensor(); + + private: + std::mutex m_mutex; + void* m_daemon = nullptr; // struct MHD_Daemon* + }; + + - title: "src/http_module_impl.cpp — the implementation" + text: | + The handler runs on a libmicrohttpd worker thread and calls + `self->readSensor()`, which goes through `modules().sensor_module`. That + cross-module call is what the SDK marshals onto the owner thread — the + whole point of this doc-test. + file: + path: http_module/src/http_module_impl.cpp + language: cpp + content: | + #include "http_module_impl.h" + + #include + #include + + #include + + // Generated at build time: defines LogosModules with the typed + // modules().sensor_module accessor. Included only in the .cpp so the impl + // header the generator parses stays free of Qt / codegen types. + #include "logos_sdk.h" + + namespace { + + // libmicrohttpd access handler. `cls` is the HttpModuleImpl*. Runs on an + // MHD worker thread; the cross-module call inside is marshaled onto the + // module's owner thread by the SDK. + MHD_Result onRequest(void* cls, struct MHD_Connection* connection, + const char* /*url*/, const char* /*method*/, + const char* /*version*/, const char* /*upload_data*/, + size_t* /*upload_data_size*/, void** /*req_cls*/) + { + auto* self = static_cast(cls); + const std::string body = + "temperature " + std::to_string(self->readSensor()) + "\n"; + + MHD_Response* response = MHD_create_response_from_buffer( + body.size(), const_cast(body.data()), MHD_RESPMEM_MUST_COPY); + MHD_add_response_header(response, "Content-Type", "text/plain; charset=utf-8"); + MHD_Result ret = MHD_queue_response(connection, MHD_HTTP_OK, response); + MHD_destroy_response(response); + return ret; + } + + } // namespace + + HttpModuleImpl::~HttpModuleImpl() + { + stop(); + } + + int64_t HttpModuleImpl::readSensor() + { + // Cross-module call. From the HTTP handler this runs on the server's + // worker thread; the SDK marshals it onto this module's owner thread. + return modules().sensor_module.readTemperature(); + } + + int64_t HttpModuleImpl::start(int64_t port) + { + std::lock_guard lock(m_mutex); + if (m_daemon) return 0; + if (port <= 0 || port > 65535) return 0; + + MHD_Daemon* daemon = MHD_start_daemon( + MHD_USE_INTERNAL_POLLING_THREAD, static_cast(port), + nullptr, nullptr, &onRequest, this, MHD_OPTION_END); + if (!daemon) return 0; + + m_daemon = daemon; + return 1; + } + + int64_t HttpModuleImpl::stop() + { + std::lock_guard lock(m_mutex); + if (!m_daemon) return 0; + MHD_stop_daemon(static_cast(m_daemon)); + m_daemon = nullptr; + return 1; + } + + - title: "Build both modules against this SDK" + step: true + text: | + Nix flakes only see git-tracked files, so initialise a repo in each module + first, then build each `.lgx`, overriding `logos-cpp-sdk` to the commit + under test. + + > The override URLs carry a `{release}` placeholder the runner expands to a + > concrete ref — locally this checkout's `HEAD`, in CI the commit being + > tested. + steps: + - title: "Initialise git repos" + run: | + (cd sensor_module && git init -q && git add -A) + (cd http_module && git init -q && git add -A) + check_file: "sensor_module/.git/HEAD" + + - title: "Build the sensor's .lgx against this SDK" + run: | + nix build 'path:./sensor_module#lgx' \ + --override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \ + -o sensor-lgx + code_block: | + nix build 'path:./sensor_module#lgx' \ + --override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + -o sensor-lgx + post_text: "The sensor package is under `./sensor-lgx/`:" + extra_run: + run: "ls sensor-lgx/*.lgx" + + - title: "Build the http module's .lgx against this SDK" + text: | + Lock `sensor_module` to the local checkout and override `logos-cpp-sdk` + in both builders, so the dependency wrapper and both plugins are built + against one consistent SDK. + run: | + nix build 'path:./http_module#lgx' \ + --override-input sensor_module 'path:./sensor_module' \ + --override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \ + --override-input sensor_module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \ + -o http-lgx + code_block: | + nix build 'path:./http_module#lgx' \ + --override-input sensor_module 'path:./sensor_module' \ + --override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + --override-input sensor_module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + -o http-lgx + post_text: "The http package is under `./http-lgx/`:" + extra_run: + run: "ls http-lgx/*.lgx" + + - title: "Build the runtime and install both modules" + step: true + text: | + Build `logoscore` and `lgpm` (against this SDK), seed the modules directory + with the capability module, and install both modules. + steps: + - title: "Build logoscore against this SDK" + run: | + nix build 'github:logos-co/logos-logoscore-cli' \ + --override-input logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \ + --override-input logos-liblogos/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \ + --override-input logos-module-client/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \ + --override-input logos-capability-module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \ + --out-link ./logos + code_block: | + nix build 'github:logos-co/logos-logoscore-cli' \ + --override-input logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + --override-input logos-liblogos/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + --override-input logos-module-client/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + --override-input logos-capability-module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + --out-link ./logos + check_file: "logos/bin/logoscore" + + - title: "Build lgpm" + run: "nix build 'github:logos-co/logos-package-manager#cli' -o lgpm" + check_file: "lgpm/bin/lgpm" + + - title: "Seed the modules directory with the capability module" + run: | + mkdir -p modules + cp -RL ./logos/modules/. ./modules/ + check_file: "modules/capability_module/manifest.json" + + - title: "Install the sensor" + run: "./lgpm/bin/lgpm --modules-dir ./modules --allow-unsigned install --file sensor-lgx/*.lgx" + expect_contains: + - "Installed to:" + + - title: "Install the http module" + run: "./lgpm/bin/lgpm --modules-dir ./modules --allow-unsigned install --file http-lgx/*.lgx" + expect_contains: + - "Installed to:" + + - title: "Confirm both modules are installed" + run: "./lgpm/bin/lgpm --modules-dir ./modules list" + expect_contains: + - "sensor_module" + - "http_module" + check_file: "modules/http_module/manifest.json" + + - title: "Serve over HTTP and scrape from the worker thread" + step: true + text: | + Start the daemon, load both modules, then start the HTTP server and `curl` + it. The `curl` triggers a request whose handler — on the server's worker + thread — calls `sensor_module.readTemperature()`. Getting `temperature 42` + back is the proof that the worker-thread cross-module call completed. + steps: + - title: "Start the daemon" + run: "sh -c './logos/bin/logoscore -D -m ./modules > logs.txt 2>&1 &'" + code_block: "logoscore -D -m ./modules > logs.txt &" + + - run: "sleep 3" + + - title: "Load the sensor (the dependency first)" + run: "./logos/bin/logoscore load-module sensor_module" + code_block: "logoscore load-module sensor_module" + expect_contains: + - "sensor_module" + + - title: "Load the http module" + run: "./logos/bin/logoscore load-module http_module" + code_block: "logoscore load-module http_module" + expect_contains: + - "http_module" + + - title: "Read the sensor directly (main thread)" + text: | + Called via `logoscore`, `readSensor()` runs on the module's own event-loop + thread — the easy case. It returns the sensor's reading: + run: "./logos/bin/logoscore call http_module readSensor" + code_block: "logoscore call http_module readSensor" + expect_contains: + - '"result":42' + + - title: "Start the HTTP server" + run: "./logos/bin/logoscore call http_module start 8080" + code_block: "logoscore call http_module start 8080" + expect_contains: + - '"result":1' + + - run: "sleep 1" + + - title: "Scrape it — the cross-module call now happens on the server thread" + text: | + The HTTP handler runs on a libmicrohttpd worker thread and calls + `sensor_module.readTemperature()` from there. The SDK marshals that call + onto the module's owner thread, so it completes and the response carries + the sensor's reading. Without the marshaling this request would hang. + run: "curl -s --max-time 15 http://127.0.0.1:8080/" + code_block: "curl http://127.0.0.1:8080/" + expect_contains: + - "temperature 42" + + - title: "Stop the HTTP server" + run: "./logos/bin/logoscore call http_module stop" + code_block: "logoscore call http_module stop" + expect_contains: + - '"result":1' + + - title: "Stop the daemon" + run: "./logos/bin/logoscore stop" + code_block: "logoscore stop" + + - run: "sleep 2" + + - title: "Confirm the daemon has stopped" + run: "./logos/bin/logoscore status || true" + code_block: "logoscore status" + expect_contains: + - '"status":"not_running"' + + - title: "Recap" + text: | + | Call site | Thread | Result | + | --------- | ------ | ------ | + | `logoscore call http_module readSensor` | module event-loop thread | `42` | + | `curl http://127.0.0.1:8080/` → HTTP handler → `readSensor()` | libmicrohttpd worker thread | `temperature 42` | + + Both reach `sensor_module.readTemperature()` through the generated + `modules().sensor_module` wrapper. The second does it from a worker thread — + and it works because the SDK marshals the call onto the module's owner + thread, where Qt Remote Objects replicas live. The module itself stays pure + C++. A green run is evidence that worker-thread inter-module calls work on + this SDK commit. diff --git a/doctests/outputs/cpp-sdk-worker-thread-http.md b/doctests/outputs/cpp-sdk-worker-thread-http.md new file mode 100644 index 0000000..666148b --- /dev/null +++ b/doctests/outputs/cpp-sdk-worker-thread-http.md @@ -0,0 +1,587 @@ +# Calling a Module From a Worker Thread (HTTP Server) + +Logos inter-module calls travel over Qt Remote Objects, whose replicas only +work on the thread that owns them — the module's main/event-loop thread. So a +module that wants to call **another** module from a *worker* thread has a +problem: the call would otherwise run on the worker thread and hang on replica +acquisition (there's no event loop there to drive it). + +That worker-thread case is not exotic — it's exactly what you hit the moment a +module embeds a server. The motivating example is a module that serves an HTTP +`/metrics` endpoint and, on each scrape, calls other modules to gather their +numbers. The HTTP server runs on its own thread; the inter-module calls happen +there. + +This doc-test proves that path works on the SDK commit under test, and that the +module stays **pure C++** — it never touches Qt to make it work. The SDK does +the marshaling: `LogosAPIClient` transparently runs `getClient` / +`invokeRemoteMethod` / `requestObject` / `onEvent` on the module's owner thread +when they're called from another thread. + +It is fully self-contained: + +1. Create `sensor_module`, a tiny **callee** with one method, `readTemperature()`. +2. Create `http_module`, a **caller** that depends on `sensor_module`, embeds a + [libmicrohttpd](https://www.gnu.org/software/libmicrohttpd/) server, and on + every HTTP request calls `sensor_module.readTemperature()` **from the server + thread** through the generated `modules().sensor_module` wrapper. +3. Build **both** against the C++ SDK commit under test, run them in + `logoscore`, start the server, and `curl` it. + +A green run means the `curl` got the sensor's reading back — i.e. the +worker-thread inter-module call completed instead of hanging. Without the SDK's +thread marshaling it would deadlock on the server thread and the `curl` would +time out. + +**What you'll build:** Two modules — a `sensor_module` callee and an `http_module` caller that embeds an HTTP server — built against this SDK commit and run in `logoscore`, where an HTTP request drives a cross-module call from the server's worker thread. + +**What you'll learn:** + +- Why inter-module calls must run on the module's owner thread (Qt Remote Objects replica affinity) +- How the SDK lets a module call another module from a worker thread without touching Qt +- How to embed a third-party C library (libmicrohttpd, via pkg-config) in a universal module +- How to drive a cross-module call from an HTTP handler and scrape it with `curl` + +## Prerequisites + +- **Nix** with flakes enabled. Install from [nixos.org](https://nixos.org/download.html), then enable flakes: + +```bash +mkdir -p ~/.config/nix +echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf +``` + +Verify: `nix flake --help >/dev/null 2>&1 && echo "Flakes enabled"` + +- **git** — nix flakes only see files tracked by git. +- **curl** — to scrape the endpoint. +- A Linux or macOS machine. + +--- + +## Step 1: Create the callee: sensor_module + +`sensor_module` is an ordinary `core` module in the pure-C++ (`interface: +universal`) style with a single method. `http_module` will call it on every +HTTP request. + +### 1.1 metadata.json + +No dependencies; `interface: universal` selects the pure-C++ pattern. + +```json +{ + "name": "sensor_module", + "version": "1.0.0", + "type": "core", + "category": "general", + "description": "A callee module: returns a temperature reading", + "main": "sensor_module_plugin", + "interface": "universal", + "dependencies": [], + + "nix": { + "packages": { + "build": [], + "runtime": [] + }, + "external_libraries": [], + "cmake": { + "find_packages": [], + "extra_sources": [] + } + } +} +``` + +### 1.2 CMakeLists.txt + +```cmake +cmake_minimum_required(VERSION 3.14) +project(SensorModulePlugin LANGUAGES CXX) + +if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT}) + include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake) +elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake") + include(cmake/LogosModule.cmake) +else() + message(FATAL_ERROR "LogosModule.cmake not found") +endif() + +logos_module( + NAME sensor_module + SOURCES + src/sensor_module_impl.h + src/sensor_module_impl.cpp +) +``` + +### 1.3 flake.nix + +```nix +{ + description = "Sensor core module - a callee for the worker-thread doc-test"; + + inputs = { + logos-module-builder.url = "github:logos-co/logos-module-builder"; + }; + + outputs = inputs@{ logos-module-builder, ... }: + logos-module-builder.lib.mkLogosModule { + src = ./.; + configFile = ./metadata.json; + flakeInputs = inputs; + }; +} +``` + +### 1.4 src/sensor_module_impl.h — the class + +A plain C++ class — no base, no Qt. Its one public method becomes callable over IPC. + +```cpp +#pragma once + +#include + +// A trivial sensor. http_module calls readTemperature() on every HTTP +// request, from its server thread. +class SensorModuleImpl { +public: + /// Returns the current temperature reading (degrees Celsius). + int64_t readTemperature(); +}; +``` + +### 1.5 src/sensor_module_impl.cpp — the implementation + +```cpp +#include "sensor_module_impl.h" + +int64_t SensorModuleImpl::readTemperature() +{ + return 42; +} +``` + +--- + +## Step 2: Create the caller: http_module + +`http_module` declares `sensor_module` as a dependency (so the builder +generates a typed `modules().sensor_module` wrapper) and embeds an HTTP +server using **libmicrohttpd**. The server runs on its own thread; its +request handler calls `sensor_module.readTemperature()` from there. The +module code never mentions Qt — the SDK marshals the cross-module call onto +the module's owner thread. + +### 2.1 metadata.json — declare the dependency and the C library + +`dependencies` lists `sensor_module`. `nix.packages.runtime` adds +`libmicrohttpd` (a build input, so the plugin can link it) and +`nix.packages.build` adds `pkg-config` so CMake can find it. + +```json +{ + "name": "http_module", + "version": "1.0.0", + "type": "core", + "category": "general", + "description": "A caller module: serves HTTP and reads sensor_module from the server thread", + "main": "http_module_plugin", + "interface": "universal", + "dependencies": ["sensor_module"], + + "nix": { + "packages": { + "build": ["pkg-config"], + "runtime": ["libmicrohttpd"] + }, + "external_libraries": [], + "cmake": { + "find_packages": [], + "extra_sources": [] + } + } +} +``` + +### 2.2 CMakeLists.txt — link libmicrohttpd + +After `logos_module(...)`, find libmicrohttpd via pkg-config and link it into the generated plugin target (`_module_plugin`). + +```cmake +cmake_minimum_required(VERSION 3.14) +project(HttpModulePlugin LANGUAGES CXX) + +if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT}) + include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake) +elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake") + include(cmake/LogosModule.cmake) +else() + message(FATAL_ERROR "LogosModule.cmake not found") +endif() + +logos_module( + NAME http_module + SOURCES + src/http_module_impl.h + src/http_module_impl.cpp +) + +find_package(PkgConfig REQUIRED) +pkg_check_modules(MHD REQUIRED IMPORTED_TARGET libmicrohttpd) +target_link_libraries(http_module_module_plugin PRIVATE PkgConfig::MHD) +``` + +### 2.3 flake.nix — add the dependency input + +Declare `sensor_module` as a flake input (the input name **must match** +the dependency name). The `path:` value is a placeholder — we lock it to +the real sensor checkout in the build step. + +```nix +{ + description = "HTTP core module - reads sensor_module from its server thread"; + + inputs = { + logos-module-builder.url = "github:logos-co/logos-module-builder"; + + # The module this one depends on. Placeholder path — locked to the + # real checkout in the build step via --override-input. + sensor_module.url = "path:/path/to/your/sensor_module"; + }; + + outputs = inputs@{ logos-module-builder, sensor_module, ... }: + logos-module-builder.lib.mkLogosModule { + src = ./.; + configFile = ./metadata.json; + flakeInputs = inputs; + }; +} +``` + +### 2.4 src/http_module_impl.h — the class + +Pure C++: `LogosModuleContext` (for `modules()`), a `std::mutex`, and an +opaque `void* m_daemon` (the libmicrohttpd handle stays out of the header +the generator parses). `start`/`stop` control the server; `readSensor` +does the cross-module call and is what the HTTP handler invokes. + +```cpp +#pragma once + +#include +#include + +#include // LogosModuleContext base + modules() + +// Serves HTTP via libmicrohttpd. On each request the server thread calls +// sensor_module through modules().sensor_module — the SDK marshals that +// call onto this module's owner thread. No Qt here. +class HttpModuleImpl : public LogosModuleContext { +public: + HttpModuleImpl() = default; + ~HttpModuleImpl(); + + /// Start the HTTP server on `port`. Returns 1 on success, 0 on + /// failure (already running / bad port / bind error). + int64_t start(int64_t port); + + /// Stop the HTTP server. Returns 1 if it was running, 0 otherwise. + int64_t stop(); + + /// Read sensor_module.readTemperature(). The HTTP handler calls this + /// from the server (worker) thread; exposed as a method so it can + /// also be driven directly for comparison. + int64_t readSensor(); + +private: + std::mutex m_mutex; + void* m_daemon = nullptr; // struct MHD_Daemon* +}; +``` + +### 2.5 src/http_module_impl.cpp — the implementation + +The handler runs on a libmicrohttpd worker thread and calls +`self->readSensor()`, which goes through `modules().sensor_module`. That +cross-module call is what the SDK marshals onto the owner thread — the +whole point of this doc-test. + +```cpp +#include "http_module_impl.h" + +#include +#include + +#include + +// Generated at build time: defines LogosModules with the typed +// modules().sensor_module accessor. Included only in the .cpp so the impl +// header the generator parses stays free of Qt / codegen types. +#include "logos_sdk.h" + +namespace { + +// libmicrohttpd access handler. `cls` is the HttpModuleImpl*. Runs on an +// MHD worker thread; the cross-module call inside is marshaled onto the +// module's owner thread by the SDK. +MHD_Result onRequest(void* cls, struct MHD_Connection* connection, + const char* /*url*/, const char* /*method*/, + const char* /*version*/, const char* /*upload_data*/, + size_t* /*upload_data_size*/, void** /*req_cls*/) +{ + auto* self = static_cast(cls); + const std::string body = + "temperature " + std::to_string(self->readSensor()) + "\n"; + + MHD_Response* response = MHD_create_response_from_buffer( + body.size(), const_cast(body.data()), MHD_RESPMEM_MUST_COPY); + MHD_add_response_header(response, "Content-Type", "text/plain; charset=utf-8"); + MHD_Result ret = MHD_queue_response(connection, MHD_HTTP_OK, response); + MHD_destroy_response(response); + return ret; +} + +} // namespace + +HttpModuleImpl::~HttpModuleImpl() +{ + stop(); +} + +int64_t HttpModuleImpl::readSensor() +{ + // Cross-module call. From the HTTP handler this runs on the server's + // worker thread; the SDK marshals it onto this module's owner thread. + return modules().sensor_module.readTemperature(); +} + +int64_t HttpModuleImpl::start(int64_t port) +{ + std::lock_guard lock(m_mutex); + if (m_daemon) return 0; + if (port <= 0 || port > 65535) return 0; + + MHD_Daemon* daemon = MHD_start_daemon( + MHD_USE_INTERNAL_POLLING_THREAD, static_cast(port), + nullptr, nullptr, &onRequest, this, MHD_OPTION_END); + if (!daemon) return 0; + + m_daemon = daemon; + return 1; +} + +int64_t HttpModuleImpl::stop() +{ + std::lock_guard lock(m_mutex); + if (!m_daemon) return 0; + MHD_stop_daemon(static_cast(m_daemon)); + m_daemon = nullptr; + return 1; +} +``` + +--- + +## Step 3: Build both modules against this SDK + +Nix flakes only see git-tracked files, so initialise a repo in each module +first, then build each `.lgx`, overriding `logos-cpp-sdk` to the commit +under test. + +> The override URLs carry a `` placeholder the runner expands to a +> concrete ref — locally this checkout's `HEAD`, in CI the commit being +> tested. + +### 3.1 Initialise git repos + +```bash +(cd sensor_module && git init -q && git add -A) +(cd http_module && git init -q && git add -A) + +``` + +### 3.2 Build the sensor's .lgx against this SDK + +```bash +nix build 'path:./sensor_module#lgx' \ + --override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + -o sensor-lgx +``` + +The sensor package is under `./sensor-lgx/`: + +```bash +ls sensor-lgx/*.lgx +``` + +### 3.3 Build the http module's .lgx against this SDK + +Lock `sensor_module` to the local checkout and override `logos-cpp-sdk` +in both builders, so the dependency wrapper and both plugins are built +against one consistent SDK. + +```bash +nix build 'path:./http_module#lgx' \ + --override-input sensor_module 'path:./sensor_module' \ + --override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + --override-input sensor_module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + -o http-lgx +``` + +The http package is under `./http-lgx/`: + +```bash +ls http-lgx/*.lgx +``` + +--- + +## Step 4: Build the runtime and install both modules + +Build `logoscore` and `lgpm` (against this SDK), seed the modules directory +with the capability module, and install both modules. + +### 4.1 Build logoscore against this SDK + +```bash +nix build 'github:logos-co/logos-logoscore-cli' \ + --override-input logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + --override-input logos-liblogos/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + --override-input logos-module-client/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + --override-input logos-capability-module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + --out-link ./logos +``` + +### 4.2 Build lgpm + +```bash +nix build 'github:logos-co/logos-package-manager#cli' -o lgpm +``` + +### 4.3 Seed the modules directory with the capability module + +```bash +mkdir -p modules +cp -RL ./logos/modules/. ./modules/ + +``` + +### 4.4 Install the sensor + +```bash +./lgpm/bin/lgpm --modules-dir ./modules --allow-unsigned install --file sensor-lgx/*.lgx +``` + +### 4.5 Install the http module + +```bash +./lgpm/bin/lgpm --modules-dir ./modules --allow-unsigned install --file http-lgx/*.lgx +``` + +### 4.6 Confirm both modules are installed + +```bash +./lgpm/bin/lgpm --modules-dir ./modules list +``` + +--- + +## Step 5: Serve over HTTP and scrape from the worker thread + +Start the daemon, load both modules, then start the HTTP server and `curl` +it. The `curl` triggers a request whose handler — on the server's worker +thread — calls `sensor_module.readTemperature()`. Getting `temperature 42` +back is the proof that the worker-thread cross-module call completed. + +### 5.1 Start the daemon + +```bash +logoscore -D -m ./modules > logs.txt & +``` + +```bash +sleep 3 +``` + +### 5.2 Load the sensor (the dependency first) + +```bash +logoscore load-module sensor_module +``` + +### 5.3 Load the http module + +```bash +logoscore load-module http_module +``` + +### 5.4 Read the sensor directly (main thread) + +Called via `logoscore`, `readSensor()` runs on the module's own event-loop +thread — the easy case. It returns the sensor's reading: + +```bash +logoscore call http_module readSensor +``` + +### 5.5 Start the HTTP server + +```bash +logoscore call http_module start 8080 +``` + +```bash +sleep 1 +``` + +### 5.6 Scrape it — the cross-module call now happens on the server thread + +The HTTP handler runs on a libmicrohttpd worker thread and calls +`sensor_module.readTemperature()` from there. The SDK marshals that call +onto the module's owner thread, so it completes and the response carries +the sensor's reading. Without the marshaling this request would hang. + +```bash +curl http://127.0.0.1:8080/ +``` + +### 5.7 Stop the HTTP server + +```bash +logoscore call http_module stop +``` + +### 5.8 Stop the daemon + +```bash +logoscore stop +``` + +```bash +sleep 2 +``` + +### 5.9 Confirm the daemon has stopped + +```bash +logoscore status +``` + +--- + +## Recap + +| Call site | Thread | Result | +| --------- | ------ | ------ | +| `logoscore call http_module readSensor` | module event-loop thread | `42` | +| `curl http://127.0.0.1:8080/` → HTTP handler → `readSensor()` | libmicrohttpd worker thread | `temperature 42` | + +Both reach `sensor_module.readTemperature()` through the generated +`modules().sensor_module` wrapper. The second does it from a worker thread — +and it works because the SDK marshals the call onto the module's owner +thread, where Qt Remote Objects replicas live. The module itself stays pure +C++. A green run is evidence that worker-thread inter-module calls work on +this SDK commit. diff --git a/doctests/run.sh b/doctests/run.sh index 3680caf..9bf884f 100755 --- a/doctests/run.sh +++ b/doctests/run.sh @@ -28,6 +28,7 @@ OUTPUT_DIR="./outputs" SPECS=( "cpp-sdk-module-runtime.test.yaml" "cpp-sdk-module-composition.test.yaml" + "cpp-sdk-worker-thread-http.test.yaml" ) # Build the doc-tests against THIS repo's current commit rather than the latest diff --git a/tests/sdk/CMakeLists.txt b/tests/sdk/CMakeLists.txt index bb10e03..964d450 100644 --- a/tests/sdk/CMakeLists.txt +++ b/tests/sdk/CMakeLists.txt @@ -39,6 +39,7 @@ add_executable(sdk_tests test_logos_api_provider.cpp test_mock_transport.cpp test_local_transport_integration.cpp + test_worker_thread_ipc.cpp test_event_system.cpp test_async_calls.cpp test_provider_dispatch.cpp diff --git a/tests/sdk/test_worker_thread_ipc.cpp b/tests/sdk/test_worker_thread_ipc.cpp new file mode 100644 index 0000000..ca0495a --- /dev/null +++ b/tests/sdk/test_worker_thread_ipc.cpp @@ -0,0 +1,127 @@ +// Regression test for worker-thread inter-module calls. +// +// Logos inter-module calls go over Qt Remote Objects, whose replicas only work +// on the thread that owns them — the module's main/event-loop thread. A module +// that calls another module from a WORKER thread (e.g. an embedded HTTP server +// serving /metrics) must therefore have that call executed on its owner thread. +// +// Before the fix, LogosAPIClient::invokeRemoteMethod ran on the *calling* +// thread. Over the remote transport that hangs on replica acquisition (no event +// loop on the worker thread). Here we use the in-process *local* transport, +// where the same root cause is observable deterministically: without the fix +// the provider runs on the worker thread; with the fix the SDK marshals the +// call onto the owner thread, so the provider runs there. +// +// This test FAILS without the marshaling change and PASSES with it. + +#include +#include +#include + +#include +#include +#include +#include + +#include "logos_api.h" +#include "logos_api_client.h" +#include "logos_api_provider.h" +#include "logos_mode.h" +#include "logos_provider_object.h" +#include "plugin_registry.h" +#include "token_manager.h" + +namespace { + +// Provider whose method records the thread it executed on. +class ThreadProbeProvider : public LogosProviderBase { +public: + QString providerName() const override { return "thread_probe"; } + QString providerVersion() const override { return "1.0.0"; } + + QVariant callMethod(const QString& methodName, const QVariantList&) override + { + if (methodName == "whichThread") { + calledThread.store(QThread::currentThread()); + return QVariant(QStringLiteral("ok")); + } + return QVariant(); + } + + QJsonArray getMethods() override { return QJsonArray(); } + + std::atomic calledThread{nullptr}; +}; + +class WorkerThreadIpcTest : public ::testing::Test { +protected: + void SetUp() override + { + m_savedMode = LogosModeConfig::getMode(); + LogosModeConfig::setMode(LogosMode::Local); + TokenManager::instance().clearAllTokens(); + } + void TearDown() override + { + PluginRegistry::unregisterPlugin("thread_probe"); + TokenManager::instance().clearAllTokens(); + LogosModeConfig::setMode(m_savedMode); + } + LogosMode m_savedMode; +}; + +} // namespace + +TEST_F(WorkerThreadIpcTest, InvokeFromWorkerThreadRunsOnOwnerThread) +{ + QThread* const ownerThread = QThread::currentThread(); + + // The ModuleProxy created by registerObject (owned by providerApi) keeps a + // raw pointer to the provider while published, so the provider must outlive + // it. Declaring `provider` before `providerApi` ensures that: at end of + // scope, providerApi (and its proxy) is destroyed first, then the provider. + ThreadProbeProvider provider; + + // Provider registered on the owner thread. + LogosAPI providerApi("thread_probe"); + ASSERT_TRUE(providerApi.getProvider()->registerObject("thread_probe", &provider)); + // Authorize the token the consumer will present, so the call reaches the + // provider instead of being rejected by the authz check. + providerApi.getProvider()->saveToken("caller", "tok"); + + // Consumer on the owner thread. Pre-seed the token so invokeRemoteMethod + // skips the capability_module token dance and calls the provider directly. + LogosAPI consumerApi("caller"); + TokenManager::instance().saveToken(QString("thread_probe"), QString("tok")); + LogosAPIClient* client = consumerApi.getClient("thread_probe"); + ASSERT_NE(client, nullptr); + + std::atomic done{false}; + std::atomic workerThread{nullptr}; + QVariant result; + + // Call from a worker thread, exactly as an HTTP server thread would. + std::thread worker([&]() { + workerThread.store(QThread::currentThread()); + result = client->invokeRemoteMethod("thread_probe", "whichThread", QVariantList()); + done.store(true); + }); + + // Pump the owner thread's event loop so the fix's BlockingQueuedConnection + // can run the call here. Bounded so a regression fails the assertion below + // instead of hanging forever. + for (int i = 0; i < 250 && !done.load(); ++i) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + worker.join(); + + ASSERT_TRUE(done.load()) << "worker-thread invokeRemoteMethod did not complete"; + EXPECT_NE(workerThread.load(), ownerThread) << "sanity: worker ran on a different thread"; + EXPECT_EQ(result.toString(), "ok") << "the call did not reach the provider"; + + // The crux: the inter-module call must execute on the owner thread, not the + // worker thread. Without the marshaling fix it runs on the worker thread. + EXPECT_EQ(provider.calledThread.load(), ownerThread) + << "inter-module call executed on the worker thread instead of the owner thread"; +}