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::ServiceServerfor request/responsehorus::ActionClient/horus::ActionServerfor 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
| Pattern | Use Case | Latency |
|---|---|---|
| Topic | Continuous data (sensor readings, commands) | ~20 ns same-process / ~200 ns cross-process SHM |
| Service | One-shot query (get parameter, check status) | ~5 us (JSON round-trip) |
| Action | Long 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
JsonWireMessagefor 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()