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
| # | 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 | read_latest(), or call recv() every tick and cache | Mostly — read_latest() exists; its T: Copy bound is the gap |
| 3 | Blocking in tick() | Keep tick() under 1ms, do I/O in init() | No — but .budget()/.deadline() report it |
| 4 | Expecting .order() to sequence main-loop nodes | It sequences tick 1 only — from tick 2 the topic graph decides. Use .deterministic(true) for an order that holds | 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 | Same 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 monitorto see what your nodes are doing