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
| Method | Returns | Description |
|---|---|---|
ServiceClient(name) | -- | Construct a client for the named service |
.call(json, timeout) | optional<string> | Send request JSON, wait for response |
operator bool() | bool | Check if the client handle is valid |
Quick Reference -- ServiceServer
| Method | Returns | Description |
|---|---|---|
ServiceServer(name) | -- | Construct a server for the named service |
.set_handler(fn) | void | Set the request handler callback |
.process() | void | Drive the server once; call in a loop on the constructing thread |
operator bool() | bool | Check if the server handle is valid |
Quick Reference -- ActionClient
| Method | Returns | Description |
|---|---|---|
ActionClient(name) | -- | Construct a client for the named action |
.send_goal(json) | GoalHandle | Send a goal and get a handle to track it |
.poll_feedback(id&, json&) | bool | Non-blocking; fills the args if feedback is available |
.poll_result(id, status&, json&) | bool | Non-blocking; true once the goal reached a terminal status |
.cancel(goal_id) | void | Request cancellation of a goal by id |
operator bool() | bool | Check if the client handle is valid |
Quick Reference -- ActionServer
| Method | Returns | Description |
|---|---|---|
ActionServer(name) | -- | Construct a server for the named action |
.set_accept_handler(fn) | void | Set the goal acceptance callback |
.set_execute_handler(fn) | void | Set the goal execution callback (runs per-goal on its own thread) |
.is_ready() | bool | Check if both handlers are set |
.process() | void | Drive the server once; call in a loop on the constructing thread |
operator bool() | bool | Check if the server handle is valid |
Quick Reference -- GoalHandle
| Method | Returns | Description |
|---|---|---|
.status() | GoalStatus | Local handle view only -- always Pending; use ActionClient::poll_result() for the real status |
.id() | uint64_t | Unique goal identifier |
.is_active() | bool | Local handle view only -- always true while the handle is valid |
.cancel() | void | Request cancellation of the goal |
operator bool() | bool | Check if the handle is valid |
Quick Reference -- GoalStatus Enum
| Value | Description |
|---|---|
GoalStatus::Pending | Goal accepted, waiting to start |
GoalStatus::Active | Goal is executing |
GoalStatus::Succeeded | Goal completed successfully |
GoalStatus::Aborted | Goal failed during execution |
GoalStatus::Canceled | Goal was canceled by client |
GoalStatus::Rejected | Goal 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_lenis IN/OUT -- on entry it holds the buffer capacity (3968 bytes,JsonWireMessage::MAX_PAYLOAD); write at most that many bytes, then set*res_lento the number written. Reporting more than the capacity discards the response- Return
truefor success,falsefor error — but note thatfalse(or a reported length larger than the buffer) does not fail the client's call. The server publishes the literal JSON bodynull, andcall()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
.srvor.actionIDL 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/actionsAPI, which uses typedServiceRequest<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 monitorand 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
- Scheduler API -- Drive
process()from a scheduled node tick - Node API -- Node lifecycle for hosting service/action servers
- Publisher and Subscriber API -- The underlying SHM transport
- C++ API Overview -- All classes at a glance
- Rust Services API -- Equivalent Rust service API
- Rust Actions API -- Equivalent Rust action API