Common Mistakes

New to HORUS? Here are the most common mistakes beginners make and how to fix them.

Every entry below carries a Could the API prevent this? line. A page like this is a list of places the library asks the reader to remember something, so each entry is also a design bug report: either the API already makes the mistake impossible (and the entry survives only to correct an expectation), or it could and does not. Recording which is which is what keeps the list shrinking instead of growing.

The five entries whose answer is it could are written up as API Papercuts — what the code does today, the change that would retire the entry, and what that change breaks. If you maintain HORUS rather than use it, start there.


1. Using Slashes in Topic Names

The Problem:

// AVOID - this works, but it is not the convention
let topic: Topic<f32> = Topic::new("sensors/lidar")?;

Why: / is an accepted topic-name character, and the Linux backend creates a nested directory for it. It works — but it buries the topic in a subdirectory of the SHM topics dir and diverges from the dot convention used everywhere else in HORUS. Use dots.

The Fix:

// CORRECT - Use dots instead
let topic: Topic<f32> = Topic::new("sensors.lidar")?;

Could the API prevent this? Yes, and it should. / is on Topic::new's allowed-character list, so the constructor accepts it and the Linux backend nests a directory for it. Dropping / from that list — or normalising it to . — would turn this entry into an error message. Until then it is a convention that no code enforces.


2. Forgetting to Call recv() Every Tick

The Problem:

fn tick(&mut self) {
    // Only check for messages sometimes
    if self.counter % 10 == 0 {
        if let Some(data) = self.sensor_sub.recv() {
            self.process(data);
        }
    }
    self.counter += 1;
}

Why: Messages can be missed if you don't check every tick. Topic uses a ring buffer (16-1024 slots by default). Once it fills up you lose messages — a point-to-point topic (one subscriber) drops the newest send and keeps the queued backlog, a broadcast topic (multiple subscribers) overwrites the oldest slot. Either way a consumer that skips ticks loses data.

The Fix:

fn tick(&mut self) {
    // ALWAYS check for new messages
    if let Some(data) = self.sensor_sub.recv() {
        self.last_data = Some(data);
    }

    // Use cached data for processing
    if self.counter % 10 == 0 {
        if let Some(ref data) = self.last_data {
            self.process(data);
        }
    }
    self.counter += 1;
}

Could the API prevent this? Partly, and it already does — this line said otherwise for two revisions. Topic has a latching accessor: read_latest() returns the newest published value without advancing the consumer position, which is exactly what "The Fix" above builds by hand out of recv() and a last_data field. For a Copy message, the manual cache is unnecessary:

fn tick(&mut self) {
    // No caching needed — read_latest() does not drain the ring, so it can be
    // called on the ticks you care about and skipped on the ones you do not.
    if self.counter % 10 == 0 {
        if let Some(data) = self.sensor_sub.read_latest() {
            self.process(data);
        }
    }
    self.counter += 1;
}

The gap is its bound. read_latest() requires T: Copy — a blanket guard against a use-after-free that is only reachable on the multi-consumer backends — so a message carrying a String or a Vec, which mistake 6 below explicitly encourages, cannot use it and is back to the hand-written cache. Widening that bound is papercut P2. Either way, dropped_count() turns silent loss into a number you can watch.


3. Blocking in tick()

The Problem:

fn tick(&mut self) {
    // WRONG - This blocks the entire scheduler!
    let data = std::fs::read_to_string("large_file.txt").unwrap();
    std::thread::sleep(Duration::from_millis(100));
}

Why: All nodes run in a single tick cycle. Blocking one node blocks them all.

The Fix:

fn init(&mut self) -> Result<()> {
    // Do slow initialization in init(), not tick()
    self.data = std::fs::read_to_string("large_file.txt")?;
    Ok(())
}

fn tick(&mut self) {
    // Keep tick() fast - ideally under 1ms
    self.process(&self.data);
}

Could the API prevent this? No — no signature distinguishes a slow call from a fast one. But the scheduler can report it, and that is the practical fix: give the node .budget(...) and .deadline(...) (or a .rate(...), which derives both) and an overrun is logged instead of quietly eating everyone else's tick. Work that is genuinely slow belongs in .compute() or .async_io(), which move it off the main tick thread entirely.


4. Expecting .order() to Sequence Main-Loop Nodes

The Problem:

// This does NOT reliably run the sensor before the controller
scheduler.add(sensor).order(0).build()?;
scheduler.add(controller).order(5).build()?;

Why: whether .order() sequences anything depends on when your nodes first touch their topics, which is not obvious from the registration code.

The scheduler builds its dependency graph from the topic metadata that exists when it starts — that is, from send()/recv() calls made during init(). Registration is lazy: Topic::new registers nothing, only the first send() or recv() does.

So if your nodes construct their topics in the constructor and first use them inside tick() — the common shape — there is no metadata at startup and the graph falls back to .order() tiers for tick 1. It does not stay there: those first send()/recv() calls register the topics, and at the start of tick 2 the scheduler rebuilds the graph from them and keeps it. From that point the pub/sub edges decide the order and .order() no longer sequences those nodes.

Use .deterministic(true) if you need an order that holds for the whole run.

A node that sends or receives during init() skips even that first tick: the graph has its edges from the start, so .order() never decides anything for it. Either way, once the graph exists, independent nodes run concurrently whatever their numbers.

The failure mode is that the same two lines behave differently in two programs. Expressing the ordering as a data dependency works in both.

The Fix: express the ordering as a data dependency — the controller subscribes to what the sensor publishes, so it cannot run first:

// sensor publishes "robot.imu"; controller subscribes to it.
// That subscription is what sequences them.
scheduler.add(sensor).build()?;
scheduler.add(controller).build()?;

.order() still does real work on the RT, compute and async I/O executors, which start their pools sorted by it, and as the tie-break before any topic metadata exists. Keep using it there — just do not rely on it to sequence main-loop nodes that talk over topics.

Could the API prevent this? No, and probably never. Nothing in a type says a watchdog matters more than a logger — that is domain knowledge only the author has. This entry stays a documentation problem.


5. Not Implementing shutdown() for Motors

The Problem:

impl Node for MotorController {
    fn name(&self) -> &'static str { "motor" }

    fn tick(&mut self) {
        self.motor.set_velocity(self.velocity);
    }

    // No shutdown() implemented!
}

Why: When you press Ctrl+C, the motor keeps running at its last velocity!

The Fix:

impl Node for MotorController {
    fn name(&self) -> &'static str { "motor" }

    fn tick(&mut self) {
        self.motor.set_velocity(self.velocity);
    }

    fn shutdown(&mut self) -> Result<()> {
        // CRITICAL: Stop motor on shutdown!
        hlog!(info, "Stopping motor for safe shutdown");
        self.motor.set_velocity(0.0);
        Ok(())
    }
}

Could the API prevent this? Yes, one layer down. Node::shutdown() has an empty default body on purpose: most nodes have nothing to wind down, and making it required would put Ok(()) in every file. The mistake disappears if the actuator handle stops itself in Drop, so dropping it is what stops the motor and forgetting shutdown() costs nothing. That is a driver contract, not a Node one.


6. Not Deriving Required Traits for Custom Messages

The Problem:

struct MyMessage {
    x: f32,
    y: f32,
}

// Error: the trait bound `MyMessage: Clone` is not satisfied
let topic: Topic<MyMessage> = Topic::new("data")?;

Why: Topic requires types to implement Clone, Serialize, and Deserialize.

The Fix:

use serde::{Serialize, Deserialize};

#[derive(Clone, Serialize, Deserialize)]
struct MyMessage {
    x: f32,
    y: f32,
    name: String,  // Strings work fine!
    data: Vec<f32>,  // Vecs work too!
}

let topic: Topic<MyMessage> = Topic::new("data")?;

Or use the standard message types which already have the required traits:

use horus::prelude::*;

let topic: Topic<CmdVel> = Topic::new("cmd_vel")?;
let topic: Topic<Odometry> = Topic::new("odom")?;

Could the API prevent this? Already does, twice over. The missing bound is a compile error, not a runtime surprise, so nothing ships broken. And message! emits #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] for you, so a type declared with it cannot be missing them:

message! {
    MyMessage { x: f32, y: f32 }
}

7. Thinking send() Returns a Result

The Problem:

fn tick(&mut self) {
    // WRONG - send() is infallible, this won't compile
    if let Err(e) = self.pub_topic.send(data) {
        hlog!(warn, "Failed to publish: {:?}", e);
    }
}

Why: send() returns () — there is no error to check. It is fire-and-forget: if the ring is full it retries briefly (64 spins + 4 yields) and then drops the new message, incrementing a counter you can read with topic.dropped_count(). A point-to-point topic is drop-newest only while a subscriber is actually draining it. When nothing is — no subscriber, a subscriber whose process is gone, or a tail that has not moved for a full lease timeout (5 s by default) — send() retires the oldest slot and takes it instead, so an unread ring holds the most recent capacity messages rather than freezing on the first ones it was given. try_send() and send_blocking() never reclaim like this: their contract is to report a full ring, not to drop from it. When loss is unacceptable use try_send(msg) -> Result<(), T>, which hands the message back, or send_blocking(msg, timeout) -> Result<(), SendBlockingError> — both only meaningful on a point-to-point topic, since a broadcast ring never reports itself full.

The Fix:

fn tick(&mut self) {
    // CORRECT - send() is infallible, just call it
    self.pub_topic.send(data);
}

Could the API prevent this? Already does. send() returns (), so if let Err(e) = ...send(...) does not compile — rustc corrects the reader, not a misbehaving robot. This entry survives only to answer "where did my error go?", and the answer is dropped_count(), try_send() and send_blocking().


8. Creating Topic Inside tick()

The Problem:

fn tick(&mut self) {
    // WRONG - Creates new Topic every tick!
    let topic: Topic<f32> = Topic::new("data").unwrap();
    topic.send(42.0);
}

Why: Creating a Topic is expensive (opens shared memory). Doing it every tick wastes resources.

The Fix:

struct MyNode {
    topic: Topic<f32>,  // Store Topic in struct
}

impl MyNode {
    fn new() -> Result<Self> {
        Ok(Self {
            topic: Topic::new("data")?,  // Create once
        })
    }
}

fn tick(&mut self) {
    self.topic.send(42.0);  // Reuse existing Topic
}

Could the API prevent this? In principle. Topic::new opens shared memory on every call, so the cost scales with the call count; if it returned a cached handle per (name, type) the mistake would cost nothing. It does not do that today, and caching brings its own hazard (deciding who closes the last handle), so for now the constructor is a thing you call once.


9. Mismatched Topic Types

The Problem:

// Publisher sends f32
let pub_topic: Topic<f32> = Topic::new("data")?;
pub_topic.send(42.0);

// Subscriber expects i32
let sub_topic: Topic<i32> = Topic::new("data")?;  // Err: Failed to create topic 'data': type mismatch. Existing type 'f32', attempted 'i32'.
let value = sub_topic.recv();  // Never reached — line above returned Err

Why: HORUS stamps the message type name into the topic's shared-memory header and validates it on every Topic::new, so a mismatched open fails loudly instead of corrupting data. The check compares short type names case-insensitively and is skipped when either side uses GenericMessage — which the Python bindings use for untyped topics — so cross-language pairings and two distinct types sharing a short name still need care.

The Fix:

// Use the SAME type for publisher and subscriber
let pub_topic: Topic<f32> = Topic::new("data")?;
let sub_topic: Topic<f32> = Topic::new("data")?;  // Same type!

Pro tip: Use named message types to avoid confusion:

type SensorReading = f32;
let pub_topic: Topic<SensorReading> = Topic::new("sensor")?;
let sub_topic: Topic<SensorReading> = Topic::new("sensor")?;

An alias is for readability only — SensorReading erases to f32, so the header still records f32 and the runtime check above behaves exactly as it would without the alias. To make that check itself tell two f32 payloads apart, give them genuinely distinct types (e.g. struct SensorReading(f32); with the derives from mistake 6), which stamps SensorReading into the header instead.

Better tip: for a message! type, open the topic with the generated Type::topic(name) constructor rather than Topic::new:

message! {
    Pose { x: f32, y: f32 }
}

// Layout-checked: refuses a peer whose Pose has different fields, in a
// different order, or of different types.
let pose = Pose::topic("robot.pose")?;

The name check below compares only the type's short name, so a peer built from Pose { y, x } — same name, same 8 bytes, coordinates swapped — opens the topic without complaint. Type::topic() supplies a LAYOUT_HASH over the name and every field's name and type, which catches exactly that. It costs nothing at runtime. See Topics & Communication.

Could the API prevent this? Half already is, and a better half exists that the default spelling declines. The type name is stamped into the topic header and Topic::new refuses a mismatched open, so the failure is immediate and loud rather than silent corruption — but that check compares a short name in a 32-byte field, case-insensitively, and skips entirely when either side is GenericMessage (which is what the Python bindings use). The stronger layout check ships today and binds only when both sides supply a hash, and Topic::new supplies none; Type::topic() does. Routing Topic::new through it is papercut P5. Making it a compile error would additionally need topic names bound to their types at declaration — a registry of ("data", f32) pairs the compiler can see — which HORUS does not have.


Not a mistake: writing impl Node by hand

Earlier versions of this page listed the hand-written trait form as mistake 10. It is not one. The trait form is the canonical HORUS style: it is what the Quick Start teaches, what the README shows, and what horus new scaffolds by default. A reader who writes it has done the normal thing.

struct MySensor {
    pub_topic: Topic<f32>,
}

impl MySensor {
    fn new() -> Result<Self> {
        Ok(Self {
            pub_topic: Topic::new("sensor.data")?,
        })
    }
}

impl Node for MySensor {
    fn name(&self) -> &str { "MySensor" }

    fn tick(&mut self) {
        let data = 42.0;  // Read sensor
        self.pub_topic.send(data);
    }
}

node! is a shorter spelling of exactly that — it generates the struct, the constructor and the impl Node — and horus new --macro scaffolds a starter written that way. Note the constructor it generates is infallible (MySensor::new(), no ?), which is the one place the two forms differ at the call site:

node! {
    MySensor {
        pub { sensor_data: f32 -> "sensor.data" }

        tick {
            let data = 42.0;  // Read sensor
            self.sensor_data.send(data);
        }
    }
}

Pick either. Mixing them in one codebase is fine too. See node! Macro for more examples.


Quick Reference

#MistakeFixCould the API prevent it?
1Slashes in topic namesUse dots: sensors.lidarYes — Topic::new could reject /
2Not checking recv() every tickread_latest(), or call recv() every tick and cacheMostly — read_latest() exists; its T: Copy bound is the gap
3Blocking in tick()Keep tick() under 1ms, do I/O in init()No — but .budget()/.deadline() report it
4Expecting .order() to sequence main-loop nodesIt sequences tick 1 only — from tick 2 the topic graph decides. Use .deterministic(true) for an order that holdsNo — only the author knows what is critical
5No shutdown() for motorsAlways stop actuators in shutdown()Yes, in the driver — stop the motor in Drop
6Missing derives on messagesAdd Clone, Serialize, DeserializeAlready does — compile error, and message! derives them
7Treating send() as falliblesend() is infallible — just call it directlyAlready does — it does not compile
8Creating Topic in tick()Create Topic once in new()In principle — Topic::new could cache per (name, type)
9Mismatched topic typesSame type both ends; open with Type::topic()Half — the name check runs by default, the layout check is opt-in

The five entries in the last column that say the API could are written up with the code, the proposed change and its cost in API Papercuts.


Still Having Issues?

  • Check Troubleshooting for error messages
  • See Examples for working code
  • Run horus monitor to see what your nodes are doing