C++ Error Handling

RAII Everywhere

All HORUS C++ types use RAII — destructors clean up automatically:

{
    horus::Scheduler sched;           // creates Rust scheduler
    horus::TensorPool pool(1, 1024*1024, 64);  // creates SHM pool
    horus::Image img(pool, 640, 480); // allocates from pool

    // ... use them ...

}  // all destroyed in reverse order, SHM cleaned up

Move-only types prevent accidental copies:

auto pub = sched.advertise<horus::msg::CmdVel>("cmd");
auto pub2 = std::move(pub);   // OK — transfer ownership
// auto pub3 = pub2;           // ERROR — copy deleted

Null Safety

Every C API function null-checks its handle argument, and most also null-check their string arguments:

horus_scheduler_destroy(nullptr);      // no-op
horus_image_width(nullptr);            // returns 0
horus_params_has(nullptr, nullptr);    // returns false
horus_action_client_send_goal(nullptr, nullptr);  // returns nullptr

A handful of functions dereference a const char* argument before checking it — passing nullptr there is undefined behavior, not a graceful no-op:

  • horus_node_builder_new(name)
  • horus_node_builder_on_topic(builder, topic)
  • horus_scheduler_name(sched, name)
  • every horus_publisher_*_new(name) and horus_subscriber_*_new(name)
  • horus_service_client_new, horus_service_server_new, horus_action_client_new, horus_action_server_new

Always pass a valid NUL-terminated string for names and topics. (The C++ wrappers in horus/*.hpp always do.)

C++ wrappers check validity:

horus::TensorPool pool(1, 1024, 4);
if (!pool) {
    // Pool creation failed (e.g., SHM permission denied)
}

auto img = horus::Image(pool, 640, 480);
if (!img) {
    // Image allocation failed (pool full)
}

What HORUS Itself Throws

Setup and run failures are reported with horus::Error, a std::runtime_error subclass from <horus/error.hpp>. It is thrown by:

CallThrown when
horus::Scheduler()the Rust scheduler could not be created
Scheduler::tick_rate(f)f is not finite and greater than 0 Hz
Scheduler::spin()the run loop returned an error
Scheduler::tick_once()the tick returned an error
NodeBuilder::build()registration failed, or the process used up its 32 callback-node trampoline slots (see the C++ Testing Guide)

These are on the ordinary path of every example in these docs, so main() needs a handler. Nothing else catches it: an uncaught throw out of spin() or build() ends the process through std::terminate.

#include <horus/horus.hpp>
#include <cstdio>

int main() {
    try {
        horus::Scheduler sched;
        sched.tick_rate(horus::Frequency(100.0));
        sched.spin();
    } catch (const horus::Error& e) {
        std::fprintf(stderr, "horus: %s\n", e.what());
        return 1;
    }
    return 0;
}

This is the opposite direction from a tick callback. HORUS throwing out to you is fine; you throwing out of a tick callback is not -- see below.

Exception Safety

The Rust scheduler calls into your C++ tick, not the other way round, and wraps each call in std::panic::catch_unwind on the Rust side:

Rust scheduler
  └─ CppNode::tick()                  // panics on entry if the node already failed
      └─ std::panic::catch_unwind
          └─ Rust closure `move || cb()`
              └─ extern "C" trampoline
                  └─ your C++ tick lambda

catch_unwind catches Rust panics raised inside the closure; the node is then marked failed and every later tick panics again on entry. It does not protect the extern "C" boundary itself — a panic raised across that boundary aborts the process.

For the same reason, a C++ exception thrown inside a tick callback must be caught before returning; unwinding through extern "C" is undefined behavior.

sched.add("safe_node")
    .tick([&] {
        try {
            risky_operation();
        } catch (const std::exception& e) {
            horus::log::error("node", e.what());
        }
    })
    .build();

Failed Nodes

If a Rust panic occurs in a node's tick, the node is disabled:

  • First panic: the adapter catches it, prints [horus_cpp] PANIC in C++ node '<name>' tick callback: ... to stderr, marks the node failed, and then re-raises it as a Rust panic so the scheduler sees a failed tick rather than a silent success.
  • Every later tick: panics again on entry, with C++ node '<name>' is disabled after N panic(s) in its tick callback. It is not a silent no-op — the panic is deliberate, so the scheduler's failure machinery keeps seeing the node as broken instead of the adapter absorbing it.
  • Other nodes continue running.

The scheduler is what contains the damage: it counts the failed tick, calls on_error(), and applies the node's FailurePolicy. A disabled C++ node therefore keeps producing a panic per tick until a policy stops it — noisy by design, so the failure cannot be missed.

Error Logging

Use horus::log for structured error reporting:

horus::log::info("sensor", "Calibration complete");
horus::log::warn("controller", "PID output near saturation");
horus::log::error("safety", "Watchdog timeout on motor driver");
horus::blackbox::record("crash", "Segfault in vision pipeline");

All messages appear in horus log CLI output.