Tutorial 6: Services & Actions (C++)

Topics are fire-and-forget. Sometimes you need a response (services) or progress updates (actions). This tutorial covers both.

What You'll Learn

  • horus::ServiceClient / horus::ServiceServer for request/response
  • horus::ActionClient / horus::ActionServer for long-running tasks
  • JSON-based type erasure for flexible RPC
  • Cross-process service calls

Services: Request/Response

A service is like a function call across processes. Client sends a request, server returns a response.

Example: Add Two Numbers

#include <horus/horus.hpp>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <atomic>
#include <optional>
#include <string>
#include <thread>
#include <chrono>
using namespace horus::literals;

int main() {
    // ── Server: listens for requests, computes response ─────────
    horus::ServiceServer server("add_two_ints");

    // Handler receives raw bytes, writes response. `res_len` is IN/OUT: on
    // entry it holds the capacity of `res` in bytes; on return, set it to the
    // number of bytes written. Never assume a capacity — read the one you're
    // given (today it is JsonWireMessage::MAX_PAYLOAD = 3968).
    server.set_handler([](const uint8_t* req, size_t req_len,
                          uint8_t* res, size_t* res_len) -> bool {
        const size_t cap = *res_len;  // IN: real buffer capacity

        // Copy the request out as a NUL-terminated string
        char json[4096] = {};
        if (req_len >= sizeof(json)) return false;
        std::memcpy(json, req, req_len);

        // Simple whitespace-tolerant parsing (production: use a JSON library).
        // The request crosses the wire verbatim, spaces and all.
        int a = 0, b = 0;
        if (const char* p = std::strstr(json, "\"a\":")) a = std::atoi(p + 4);
        if (const char* p = std::strstr(json, "\"b\":")) b = std::atoi(p + 4);

        // Compute response
        int sum = a + b;
        int n = std::snprintf(reinterpret_cast<char*>(res), cap,
                              R"({"sum":%d})", sum);
        if (n < 0 || static_cast<size_t>(n) >= cap) return false;
        *res_len = static_cast<size_t>(n);  // OUT: bytes written
        return true;
    });

    // ── Client: sends request, waits for response ───────────────
    horus::ServiceClient client("add_two_ints");
    std::this_thread::sleep_for(std::chrono::milliseconds(50));  // let SHM settle

    // The server must be driven to answer requests, and process() may only be
    // called from the thread that constructed the server. Since call() blocks,
    // run the call on a worker thread and pump process() here on main; in a
    // real app the server lives in its own process and process() is just part
    // of its main loop (or a scheduled node tick).
    std::atomic<bool> done{false};
    std::optional<std::string> response;
    std::thread caller([&] {
        response = client.call(R"({"a": 3, "b": 4})",
                               std::chrono::milliseconds(1000));
        done.store(true);
    });

    auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
    while (!done.load()) {
        server.process();
        if (std::chrono::steady_clock::now() > deadline) break;
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
    }
    caller.join();

    if (response) {
        std::printf("Response: %s\n", response->c_str());
        // Output: Response: {"sum":7}
    } else {
        std::printf("Service call timed out\n");
    }
}

How Services Work

Client                          Server
  │                               │
  │─── Request JSON ──────────→  │
  │    (via SHM topic)           │ handler() called
  │                               │ computes response
  │  ←── Response JSON ──────── │
  │    (via SHM topic)           │

Under the hood, services use JsonWireMessage Pod transport over two SHM topics:

  • {service_name}.request — client publishes, server subscribes
  • {service_name}.response.{client_pid} — server publishes, client subscribes

Actions: Long-Running Tasks with Progress

Actions are for tasks that take time — navigating to a goal, calibrating a sensor, recording data.

Example: Navigate to Goal

This is the client half only. Feedback and results reach a client solely because some ActionServer is being driven — process() is what publishes onto the client's feedback/result topics. Build the Action Server shown below into its own binary and start it first, or the poll loop has nothing to receive.

#include <horus/horus.hpp>
#include <cstdio>
#include <thread>
#include <chrono>
using namespace horus::literals;

int main() {
    // ── Client: send goal, monitor progress ─────────────────────
    // Requires the "navigate" action server (shown below) running elsewhere.
    horus::ActionClient client("navigate");

    auto goal = client.send_goal(R"({"target_x": 5.0, "target_y": 3.0})");
    if (!goal) {
        std::printf("Failed to send goal\n");
        return 1;
    }

    std::printf("Goal sent (id=%lu)\n", goal.id());

    // Poll for feedback and the final result. The client handle's own status()
    // is local; live status/feedback/result arrive over the topics via poll_*.
    horus::GoalStatus status;
    std::string result_json;
    uint64_t fb_id;
    std::string fb_json;
    // Always bound the poll loop: with no server driving process(), no result
    // is ever published and an unbounded loop would spin forever.
    auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
    while (!client.poll_result(goal.id(), status, result_json)) {
        if (client.poll_feedback(fb_id, fb_json)) {
            std::printf("  feedback: %s\n", fb_json.c_str());
        }
        // client.cancel(goal.id());  // request cancellation at any time
        if (std::chrono::steady_clock::now() > deadline) {
            std::printf("No result — is the 'navigate' action server running?\n");
            return 1;
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(5));
    }

    std::printf("Final status: %d, result: %s\n",
        static_cast<int>(status), result_json.c_str());
}

Action Lifecycle

Client                          Server
  │                               │
  │── Goal JSON ──────────────→  │  accept_handler() → accept/reject
  │                               │
  │  ←── Feedback JSON ────────  │  (periodic progress updates)
  │  ←── Feedback JSON ────────  │
  │  ←── Feedback JSON ────────  │
  │                               │
  │  ←── Result JSON ──────────  │  (final result)
  │                               │
  │── Cancel ──────────────────→  │  (optional, client-initiated)

Action Server

Each accepted goal runs on its own thread, so the server stays responsive to cancellation while a goal executes. Drive the server by calling process() in a loop on the thread that created it.

horus::ActionServer server("navigate");

// Accept handler: decide whether to accept the goal (0 = accept, 1 = reject).
server.set_accept_handler([](const uint8_t* goal, size_t len) -> uint8_t {
    return 0;  // accept all goals
});

// Execute handler: runs on a dedicated thread per accepted goal. Wrap the raw
// handle, poll for cancellation, publish feedback, and finish the goal.
server.set_execute_handler([](HorusActionGoalHandle* h, const uint8_t* goal, size_t len) {
    horus::ActionGoalHandle g(h);
    while (!g.is_cancel_requested()) {
        // ... do a step of work ...
        g.publish_feedback(R"({"progress": 0.5})");
        if (/* arrived */ true) {
            g.succeed(R"({"success": true})");
            return;
        }
    }
    g.canceled(R"({"success": false})");  // cancellation was requested
});

// Drive the server (same thread it was constructed on).
while (running) {
    server.process();
    std::this_thread::sleep_for(std::chrono::milliseconds(5));
}

If the execute handler returns without calling succeed/abort/canceled, the goal is completed as Aborted. A HorusActionGoalHandle* is valid only for the duration of the execute call — never stash it and use it later.

Cross-Process Services

Services work across processes — client and server can be separate binaries:

# Terminal 1: server
./my_service_server

# Terminal 2: client
./my_service_client

Both connect through SHM. The service names must match — the underlying {name}.request / {name}.response.{pid} topics are derived from them.

When To Use What

PatternUse CaseLatency
TopicContinuous data (sensor readings, commands)~20 ns same-process / ~200 ns cross-process SHM
ServiceOne-shot query (get parameter, check status)~5 us (JSON round-trip)
ActionLong task with progress (navigate, calibrate)~5 us + task duration

Key Takeaways

  • Services = synchronous request/response (like function calls)
  • Actions = asynchronous goal/feedback/result (like background tasks)
  • Both use JsonWireMessage for type-erased communication
  • Both work same-process and cross-process via SHM
  • Client must specify timeout for services (network could delay)
  • Actions support cancellation via goal.cancel()