logos-module-client is being archived: nothing declares it as a flake input, nothing lists it as a dependency in the workspace dep-graph, and no source or header outside its own tree references it. These three generated outputs were the last thing implying otherwise. Two were prose describing it as a repo that pins the SDK independently; three were a live `--override-input logos-module-client/logos-cpp-sdk ...` line inside a command a reader is invited to run. Overriding an input that does not exist is only a nix WARNING, not an error, so the command would appear to work while doing nothing at that path. The live specs in doctests/*.yaml are already clean — they emit no such override. Only outputs/*.md drifted, which is expected: they are hand-pinned and CI never diffs them against the generator, so nothing was going to catch this. Docs only; no spec, code or lock changes. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
16 KiB
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:
- Create
sensor_module, a tiny callee with one method,readTemperature(). - Create
http_module, a caller that depends onsensor_module, embeds a libmicrohttpd server, and on every HTTP request callssensor_module.readTemperature()from the server thread through the generatedmodules().sensor_modulewrapper. - Build both against the C++ SDK commit under test, run them in
logoscore, start the server, andcurlit.
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, then enable flakes:
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.
{
"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_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
{
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.
#pragma once
#include <cstdint>
// 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
#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.
{
"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 (<name>_module_plugin).
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.
{
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.
#pragma once
#include <cstdint>
#include <mutex>
#include <logos_module_context.h> // 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.
#include "http_module_impl.h"
#include <cstdint>
#include <string>
#include <microhttpd.h>
// 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<HttpModuleImpl*>(cls);
const std::string body =
"temperature " + std::to_string(self->readSensor()) + "\n";
MHD_Response* response = MHD_create_response_from_buffer(
body.size(), const_cast<char*>(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<std::mutex> 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<uint16_t>(port),
nullptr, nullptr, &onRequest, this, MHD_OPTION_END);
if (!daemon) return 0;
m_daemon = daemon;
return 1;
}
int64_t HttpModuleImpl::stop()
{
std::lock_guard<std::mutex> lock(m_mutex);
if (!m_daemon) return 0;
MHD_stop_daemon(static_cast<MHD_Daemon*>(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
(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
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/:
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.
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/:
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
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-capability-module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \
--out-link ./logos
4.2 Build lgpm
nix build 'github:logos-co/logos-package-manager#cli' -o lgpm
4.3 Seed the modules directory with the capability module
mkdir -p modules
cp -RL ./logos/modules/. ./modules/
4.4 Install the sensor
./lgpm/bin/lgpm --modules-dir ./modules --allow-unsigned install --file sensor-lgx/*.lgx
4.5 Install the http module
./lgpm/bin/lgpm --modules-dir ./modules --allow-unsigned install --file http-lgx/*.lgx
4.6 Confirm both modules are installed
./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
logoscore -D -m ./modules > logs.txt &
sleep 3
5.2 Load the sensor (the dependency first)
logoscore load-module sensor_module
5.3 Load the http module
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:
logoscore call http_module readSensor
5.5 Start the HTTP server
logoscore call http_module start 8080
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.
curl http://127.0.0.1:8080/
5.7 Stop the HTTP server
logoscore call http_module stop
5.8 Stop the daemon
logoscore stop
sleep 2
5.9 Confirm the daemon has stopped
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.