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 'github:logos-co/logos-module-builder{release}' \ --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 'github:logos-co/logos-module-builder{release}' \ --override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \ --override-input sensor_module/logos-module-builder 'github:logos-co/logos-module-builder{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{release}' \ --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 'github:logos-co/logos-module-builder{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.