Files
logos-cpp-sdk/doctests/cpp-sdk-worker-thread-http.test.yaml

595 lines
25 KiB
YAML
Raw Permalink Normal View History

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 <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();
};
- 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 (`<name>_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 <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*
};
- 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 <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;
}
- 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}' \
--override-input logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
-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' \
--override-input logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
-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 logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--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}' \
--override-input sensor_module/logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
-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 logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--override-input sensor_module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \
--override-input sensor_module/logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
-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-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--override-input logos-liblogos/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \
--override-input logos-liblogos/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--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}' \
--override-input logos-capability-module/logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--override-input logos-capability-module/logos-module-builder/logos-test-framework/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--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-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--override-input logos-liblogos/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \
--override-input logos-liblogos/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--override-input logos-capability-module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \
--override-input logos-capability-module/logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--override-input logos-capability-module/logos-module-builder/logos-test-framework/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--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.