Services and Actions API

HORUS provides two communication patterns beyond pub/sub topics: services for synchronous request/response RPC, and actions for long-running tasks with progress feedback and cancellation. Both use JSON as the wire format over shared memory topics, so they work same-process and cross-process with zero configuration.

Rust: See Services and Actions for the Rust API. Python: services and actions are not exposed by the Python bindings -- use the Rust or C++ API.

// simplified
#include <horus/service.hpp>
#include <horus/action.hpp>

Quick Reference -- ServiceClient

MethodReturnsDescription
ServiceClient(name)--Construct a client for the named service
.call(json, timeout)optional<string>Send request JSON, wait for response
operator bool()boolCheck if the client handle is valid

Quick Reference -- ServiceServer

MethodReturnsDescription
ServiceServer(name)--Construct a server for the named service
.set_handler(fn)voidSet the request handler callback
.process()voidDrive the server once; call in a loop on the constructing thread
operator bool()boolCheck if the server handle is valid

Quick Reference -- ActionClient

MethodReturnsDescription
ActionClient(name)--Construct a client for the named action
.send_goal(json)GoalHandleSend a goal and get a handle to track it
.poll_feedback(id&, json&)boolNon-blocking; fills the args if feedback is available
.poll_result(id, status&, json&)boolNon-blocking; true once the goal reached a terminal status
.cancel(goal_id)voidRequest cancellation of a goal by id
operator bool()boolCheck if the client handle is valid

Quick Reference -- ActionServer

MethodReturnsDescription
ActionServer(name)--Construct a server for the named action
.set_accept_handler(fn)voidSet the goal acceptance callback
.set_execute_handler(fn)voidSet the goal execution callback (runs per-goal on its own thread)
.is_ready()boolCheck if both handlers are set
.process()voidDrive the server once; call in a loop on the constructing thread
operator bool()boolCheck if the server handle is valid

Quick Reference -- GoalHandle

MethodReturnsDescription
.status()GoalStatusLocal handle view only -- always Pending; use ActionClient::poll_result() for the real status
.id()uint64_tUnique goal identifier
.is_active()boolLocal handle view only -- always true while the handle is valid
.cancel()voidRequest cancellation of the goal
operator bool()boolCheck if the handle is valid

Quick Reference -- GoalStatus Enum

ValueDescription
GoalStatus::PendingGoal accepted, waiting to start
GoalStatus::ActiveGoal is executing
GoalStatus::SucceededGoal completed successfully
GoalStatus::AbortedGoal failed during execution
GoalStatus::CanceledGoal was canceled by client
GoalStatus::RejectedGoal was rejected by server

Services -- Request/Response RPC

Services implement a synchronous request/response pattern. A client sends a JSON request and blocks until the server responds or a timeout expires.

ServiceClient

Create a client by name. Call .call() with a JSON string and a timeout:

#include <horus/service.hpp>
#include <chrono>

using namespace std::chrono_literals;

horus::ServiceClient client("add_two_ints");

// Check that the handle was created successfully
if (!client) {
    fprintf(stderr, "Failed to create service client\n");
    return;
}

// Send request, wait up to 1 second for response
auto response = client.call(R"({"a": 3, "b": 4})", 1000ms);

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

The call() method accepts both const char* and const std::string&:

using namespace std::chrono_literals;   // for the `ms` suffix
// (HORUS's own suffix is `500_ms`, from `using namespace horus::literals;`)

// String literal
auto r1 = client.call(R"({"x": 1.0})", 500ms);

// std::string
std::string request = R"({"x": 1.0, "y": 2.0})";
auto r2 = client.call(request, 500ms);

ServiceServer

Create a server and set a handler function. The handler receives raw bytes (the JSON request) and writes raw bytes (the JSON response):

#include <horus/service.hpp>
#include <atomic>
#include <chrono>
#include <cstring>
#include <cstdio>
#include <thread>

std::atomic<bool> running{true};  // cleared from your shutdown path

horus::ServiceServer server("add_two_ints");

server.set_handler([](const uint8_t* req, size_t req_len,
                      uint8_t* res, size_t* res_len) -> bool {
    // Parse request (in production, use a JSON library)
    // For this example, assume req is: {"a": 3, "b": 4}
    int a = 3, b = 4;  // parsed from req

    // Write response. *res_len arrives holding the buffer capacity -- never
    // hard-code a size here.
    int written = snprintf(reinterpret_cast<char*>(res), *res_len,
                           R"({"sum": %d})", a + b);
    if (written < 0 || static_cast<size_t>(written) >= *res_len) return false;
    *res_len = static_cast<size_t>(written);
    return true;  // true = success, false = error
});

// Drive the server so it answers requests. Call in a loop on the constructing
// thread (or from a scheduled node tick).
while (running) {
    server.process();
    std::this_thread::sleep_for(std::chrono::milliseconds(1));
}

The handler signature is:

using Handler = bool(*)(const uint8_t* req, size_t req_len,
                        uint8_t* res, size_t* res_len);
  • req / req_len: Request payload (JSON bytes)
  • res / res_len: Response buffer. *res_len is IN/OUT -- on entry it holds the buffer capacity (3968 bytes, JsonWireMessage::MAX_PAYLOAD); write at most that many bytes, then set *res_len to the number written. Reporting more than the capacity discards the response
  • Return true for success, false for error — but note that false (or a reported length larger than the buffer) does not fail the client's call. The server publishes the literal JSON body null, and call() returns that string successfully, so check for "null" on the client if you need to tell the two apart

Actions -- Long-Running Tasks

Actions handle operations that take time to complete, like navigation or trajectory execution. The client sends a goal and gets a GoalHandle to track progress and request cancellation. The server accepts or rejects goals and executes them asynchronously.

ActionClient

Create a client, send a goal as JSON, and track it via GoalHandle:

#include <horus/action.hpp>
#include <chrono>
#include <cstdio>
#include <string>
#include <thread>

horus::ActionClient client("navigate_to_pose");
if (!client) return;

auto goal = client.send_goal(R"({"target_x": 5.0, "target_y": 3.0})");
if (!goal) return;

printf("Goal %lu submitted\n", goal.id());

// Drain feedback and poll the result topic until the goal reaches a terminal status
horus::GoalStatus status = horus::GoalStatus::Pending;
std::string result_json;
uint64_t fb_id = 0;
std::string fb_json;
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
while (std::chrono::steady_clock::now() < deadline) {
    // One feedback topic per client, not per goal -- fb_id says which goal it is
    while (client.poll_feedback(fb_id, fb_json)) {
        printf("goal %lu feedback: %s\n", fb_id, fb_json.c_str());
    }
    if (client.poll_result(goal.id(), status, result_json)) break;
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
}

if (status == horus::GoalStatus::Succeeded) {
    printf("Navigation complete: %s\n", result_json.c_str());
}

GoalHandle::status() and GoalHandle::is_active() report only the handle's local view -- the status is fixed at Pending when the handle is created and is never updated, so is_active() is always true. The terminal status comes from ActionClient::poll_result(), which reads the {name}.result.{client_pid} topic.

Canceling a Goal

Call cancel() on the GoalHandle to request cancellation:

auto goal = client.send_goal(R"({"x": 10.0, "y": 0.0})");
// Cancel after 5 seconds. There is no client-side liveness check to gate this on:
// `goal.is_active()` is always true (see above), so just publish the cancel.
std::this_thread::sleep_for(std::chrono::seconds(5));
goal.cancel();

Goal Lifecycle (Server Side)

send_goal() --> Pending --> Active --> Succeeded
                  |           |
                  |           +--> Aborted (server error)
                  |           +--> Canceled (client cancel)
                  |
                  +--> Rejected (server rejects)

This diagram is the server-side lifecycle. The client's GoalHandle does not follow it: its status() is a local field fixed at Pending when send_goal() returns, so is_active() stays true for as long as the handle is valid. Use ActionClient::poll_result() to observe the terminal status.

ActionServer

Create a server with two handlers: one to accept/reject goals, one to execute them:

#include <horus/action.hpp>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <thread>

std::atomic<bool> running{true};  // cleared from your shutdown path

horus::ActionServer server("navigate_to_pose");

// Accept handler: receives goal data, returns 0 to accept, 1 to reject
server.set_accept_handler([](const uint8_t* goal_data, size_t len) -> uint8_t {
    // Parse goal JSON, validate parameters
    // Return 0 = accept, 1 = reject
    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_data, size_t len) {
    horus::ActionGoalHandle g(h);
    bool done = false;
    while (!g.is_cancel_requested()) {
        g.publish_feedback(R"({"progress": 0.5})");
        // ... do a slice of work; set done = true when finished ...
        if (done) {
            g.succeed(R"({"arrived": true})");
            return;
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
    }
    g.canceled(R"({"arrived": false})");
});

// Verify both handlers are set.
if (server.is_ready()) {
    printf("Action server ready\n");
}

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

Each goal runs on its own thread, so cancellation is delivered while a goal is executing. If the handler returns without calling succeed/abort/canceled, the goal completes as Aborted. The handler signatures are:

using AcceptHandler  = uint8_t(*)(const uint8_t* goal, size_t len);
using ExecuteHandler = void(*)(HorusActionGoalHandle* handle, const uint8_t* goal, size_t len);

JSON Wire Transport

Services and actions use JSON as the wire format, serialized into JsonWireMessage Pod structs (4 KB each) transported over SHM topics. This design means:

  • No code generation: No .srv or .action IDL files. Just send/receive JSON strings
  • C++-only wire format: the JSON wire format is shared by all C++ service/action clients and servers. It is not wire-compatible with the Rust horus_core::services/actions API, which uses typed ServiceRequest<Req>/ServiceResponse<Res> messages -- opening the same topic name from both fails with a topic type-mismatch error
  • Cross-process: Uses the same SHM transport as topics -- works across processes automatically
  • Debugging: JSON payloads are human-readable in horus monitor and BlackBox recordings

Topic Layout

Each service creates two internal topics:

{name}.request                    -- client sends request here
{name}.response.{client_pid}      -- server sends response here (per-client)

Each action creates three shared topics (goal, cancel, status) plus a per-client feedback and result topic:

{name}.goal                       -- client sends goals here
{name}.cancel                     -- client requests cancellation here
{name}.feedback.{client_pid}      -- server publishes progress here (per-client)
{name}.result.{client_pid}        -- server publishes final result here (per-client)
{name}.status                     -- server publishes lifecycle status here

These are standard HORUS topics -- you can monitor them with horus topic list and horus monitor.

Ownership and Move Semantics

The owning handles -- ServiceClient, ServiceServer, ActionClient, ActionServer and GoalHandle -- are move-only. Copy is deleted:

horus::ServiceClient a("my_svc");
// horus::ServiceClient b = a;           // COMPILE ERROR
horus::ServiceClient b = std::move(a);   // OK

horus::GoalHandle g = client.send_goal("{}");
// horus::GoalHandle g2 = g;             // COMPILE ERROR
horus::GoalHandle g2 = std::move(g);     // OK

Resources are released in destructors via the C FFI (horus_*_destroy functions). Use RAII -- do not call destroy manually.

ActionGoalHandle is the exception: it owns nothing, has no destructor, and is freely copyable. It is a non-owning view of a handle that lives on the goal thread's stack, valid only for the duration of the execute call. Do not stash it (or a copy of it) and call succeed() / publish_feedback() after the handler returns -- that is a use-after-free.


Common Patterns

Service with JSON Library

In production, use a JSON library (nlohmann/json, rapidjson, simdjson) for parsing:

#include <horus/service.hpp>
#include <nlohmann/json.hpp>

using json = nlohmann::json;

horus::ServiceServer server("compute_ik");

server.set_handler([](const uint8_t* req, size_t req_len,
                      uint8_t* res, size_t* res_len) -> bool {
    auto request = json::parse(req, req + req_len);

    double x = request["target_x"];
    double y = request["target_y"];
    double z = request["target_z"];

    // Compute inverse kinematics...
    json response = {
        {"joint_angles", {0.1, 0.5, -0.3, 0.0, 1.2, 0.0}},
        {"success", true}
    };

    std::string resp_str = response.dump();
    if (resp_str.size() > *res_len) return false;  // *res_len = buffer capacity
    std::memcpy(res, resp_str.data(), resp_str.size());
    *res_len = resp_str.size();
    return true;
});

Cross-Process Service Call

Services work across processes with no extra configuration. Start the server in one process and the client in another -- they communicate via SHM automatically:

#include <atomic>
#include <thread>

// Process A: server
std::atomic<bool> running{true};  // cleared from your shutdown path

horus::ServiceServer server("robot.status");
server.set_handler([](const uint8_t*, size_t,
                      uint8_t* res, size_t* res_len) -> bool {
    const char* s = R"({"battery": 85, "state": "idle"})";
    size_t n = strlen(s);
    if (n > *res_len) return false;   // *res_len arrives holding the capacity
    std::memcpy(res, s, n);
    *res_len = n;
    return true;
});
while (running) {          // drive the server to answer requests
    server.process();
    std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
// Process B: client
horus::ServiceClient client("robot.status");
auto resp = client.call("{}", std::chrono::milliseconds(500));
if (resp) printf("Robot status: %s\n", resp->c_str());

Action with Scheduler Integration

Run an action server inside a scheduled node for navigation:

#include <atomic>
#include <thread>

// Accept/execute handlers are function pointers, so shared state is file-scope.
static std::atomic<bool> nav_active{false};
static std::atomic<bool> nav_done{false};

horus::ActionServer nav_server("navigate");

nav_server.set_accept_handler([](const uint8_t*, size_t) -> uint8_t {
    return nav_active.load() ? 1 : 0;  // reject if already navigating
});

// The goal runs on its own thread. Keep sensor/motor topic I/O OFF this thread
// (topics are single-producer): hand control to the scheduled node via a flag
// and just wait here for completion or cancellation.
nav_server.set_execute_handler([](HorusActionGoalHandle* h, const uint8_t* /*data*/, size_t) {
    horus::ActionGoalHandle g(h);
    nav_done = false;
    nav_active = true;
    while (!nav_done.load()) {
        if (g.is_cancel_requested()) {
            nav_active = false;
            g.canceled(R"({"arrived": false})");
            return;
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }
    g.succeed(R"({"arrived": true})");
});

auto odom_sub = sched.subscribe<horus::msg::Odometry>("odom");
auto cmd_pub  = sched.advertise<horus::msg::CmdVel>("motor.cmd");

// This node pumps the action server AND runs the sensor->motor control loop, so
// all topic I/O stays on one thread.
sched.add("nav_executor").rate(50_hz)
    .tick([&] {
        nav_server.process();  // deliver goals/cancels, publish feedback/results
        if (!nav_active.load()) return;
        auto odom = odom_sub.recv();
        if (!odom) return;
        double dist = std::sqrt(/* dx^2 + dy^2 */);
        if (dist < 0.1) {
            cmd_pub.send(horus::msg::CmdVel{0, 0.0f, 0.0f});
            nav_done = true;  // signals the goal thread to succeed
        } else {
            cmd_pub.send(horus::msg::CmdVel{0, 0.3f, 0.0f});
        }
    }).build();

See Also