Files
logos-cpp-sdk/doctests/outputs/cpp-sdk-worker-thread-http.md
Dario LipicarandClaude Opus 5 f291efa2e8 docs(doctests): drop references to the archived logos-module-client (#140)
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>
2026-08-20 09:48:48 -03:00

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:

  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 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, 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": []
    }
  }
}

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.