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.
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? Not today. Topic exposes recv(),
has_message(), pending_count() and dropped_count(), but no latching
accessor: there is no latest() that returns the newest value without draining
the ring, which is exactly what a node that only samples every tenth tick
wants. Adding one would make the manual caching in "The Fix" unnecessary. Until
then dropped_count() at least 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. Wrong Priority Order
The Problem:
// WRONG - Logger runs before sensor!
scheduler.add(logger).order(0).build()?; // Order 0 (runs first)
scheduler.add(sensor).order(10).build()?; // Order 10
scheduler.add(controller).order(5).build()?; // Order 5
Why: Lower order number = runs first. Safety-critical code should be order 0.
The Fix:
// CORRECT - Proper ordering
scheduler.add(safety_monitor).order(0).build()?; // Safety first!
scheduler.add(sensor).order(5).build()?; // Then sensors
scheduler.add(controller).order(10).build()?; // Then control
scheduler.add(logger).order(100).build()?; // Logging last
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(). Point-to-point topics are drop-newest, not drop-oldest. 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.
Could the API prevent this? Half of it already is. 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. Making it a compile error
would need topic names bound to their types at declaration — a registry of
("data", f32) pairs the compiler can see — which HORUS does not have. Two gaps
stay open regardless: GenericMessage skips the check, and two distinct types
can share a short name.
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
| # | Mistake | Fix | Could the API prevent it? |
|---|---|---|---|
| 1 | Slashes in topic names | Use dots: sensors.lidar | Yes — Topic::new could reject / |
| 2 | Not checking recv() every tick | Always call recv(), cache last value | Not yet — needs a latching latest() |
| 3 | Blocking in tick() | Keep tick() under 1ms, do I/O in init() | No — but .budget()/.deadline() report it |
| 4 | Wrong priority order | Lower number = higher priority | No — only the author knows what is critical |
| 5 | No shutdown() for motors | Always stop actuators in shutdown() | Yes, in the driver — stop the motor in Drop |
| 6 | Missing derives on messages | Add Clone, Serialize, Deserialize | Already does — compile error, and message! derives them |
| 7 | Treating send() as fallible | send() is infallible — just call it directly | Already does — it does not compile |
| 8 | Creating Topic in tick() | Create Topic once in new() | In principle — Topic::new could cache per (name, type) |
| 9 | Mismatched topic types | Use same type for pub and sub | Half — refused at runtime; a compile error needs a topic registry |
Still Having Issues?
- Check Troubleshooting for error messages
- See Examples for working code
- Run
horus monitorto see what your nodes are doing