Migrating from ROS2 (Rust)
This guide shows ROS2 rclrs patterns and their HORUS equivalents side by side.
ROS2 ships official client libraries for C++ (rclcpp) and Python (rclpy) only. Rust support comes from rclrs, part of the community ros2_rust project: it is not part of a ROS2 distribution, its message crates are produced by a colcon build, and its API still changes between releases. The ROS2 column below shows the shape of that API rather than a version-pinned snippet — check it against the release you are on. If the stack you are actually migrating is rclcpp or rclpy, the C++ and Python pages line up with it more directly.
Node Definition
ROS2 (24 lines)
// Lives in a colcon workspace: rclrs, cargo-ament-build, package.xml, the
// generated sensor_msgs and geometry_msgs crates, ...
use geometry_msgs::msg::Twist;
use sensor_msgs::msg::LaserScan;
fn main() -> Result<(), rclrs::RclrsError> {
let context = rclrs::Context::new(std::env::args())?;
let node = rclrs::create_node(&context, "controller")?;
let publisher =
node.create_publisher::<Twist>("cmd_vel", rclrs::QOS_PROFILE_DEFAULT)?;
let _subscription = node.create_subscription::<LaserScan, _>(
"scan",
rclrs::QOS_PROFILE_DEFAULT,
move |msg: LaserScan| {
let mut cmd = Twist::default();
cmd.linear.x = if msg.ranges[0] > 0.5 { 0.3 } else { 0.0 };
publisher.publish(cmd).unwrap();
},
)?;
rclrs::spin(&node)
}
HORUS (26 lines)
use horus::prelude::*;
struct Controller {
scan: Topic<LaserScan>,
cmd: Topic<CmdVel>,
}
impl Node for Controller {
fn name(&self) -> &str { "controller" }
fn tick(&mut self) {
let Some(scan) = self.scan.recv() else { return };
let linear = if scan.ranges[0] > 0.5 { 0.3 } else { 0.0 };
self.cmd.send(CmdVel::new(linear, 0.0));
}
}
fn main() -> Result<()> {
let controller = Controller {
scan: Topic::new("scan")?,
cmd: Topic::new("cmd_vel")?,
};
let mut sched = Scheduler::new().tick_rate(100_u64.hz());
sched.add(controller).order(0).build()?;
sched.run()
}
The two files are within a couple of lines of each other — that part of the C++ comparison does not survive the move to Rust, and pretending otherwise would be dishonest. What changes is everything around the file. The HORUS version is an ordinary cargo binary: cargo build compiles it, horus run runs it. The rclrs version needs a sourced ROS2 installation, a colcon workspace, cargo-ament-build, a package.xml, and message crates generated by that workspace before LaserScan is even a type.
Pattern Comparison
| Concept | ROS2 rclrs | HORUS Rust |
|---|---|---|
| Node | rclrs::create_node(&ctx, "name") | impl Node for YourStruct |
| Publisher | node.create_publisher::<T>(topic, qos)? | Topic::<T>::new(topic)? |
| Subscriber | node.create_subscription::<T, _>(topic, qos, cb)? | the same Topic<T> |
| Callback | closure passed to create_subscription | fn tick(&mut self) calling recv() |
| Node state | Arc<Mutex<State>> captured by the closure | plain fields behind &mut self |
| Handles | Arc<Node>, Arc<Publisher<T>> | value types owned by your struct |
| Message | sensor_msgs::msg::LaserScan (generated crate) | LaserScan from horus::prelude |
| Message layout | ranges: Vec<f32> — heap allocated | ranges: [f32; 360] — #[repr(C)], no allocation |
| Publish | publisher.publish(msg)? — fallible, serializes | topic.send(msg) — infallible, writes the struct |
| Receive | callback-driven | poll: topic.recv() → Option<T> |
| Init | Context::new(env::args())? | nothing needed |
| Run | rclrs::spin(&node)? | sched.run() |
| Rate | create_wall_timer or a manual sleep loop | .tick_rate(100_u64.hz()), per-node .rate() |
| QoS | QOS_PROFILE_DEFAULT | .budget(5_u64.ms()).on_miss(Miss::Skip) |
| Build | colcon + cargo-ament-build + package.xml | cargo + horus.toml |
Key Differences
No Inheritance
There is nothing to inherit from in either framework — Rust has no inheritance — but rclrs still hands you a framework object (Arc<Node>) that owns your publishers and drives your callbacks. In HORUS, Node is a plain trait you implement on a struct you declared. The scheduler calls tick(&mut self); the struct is yours.
If even that struct is boilerplate you would rather not write, the node! macro generates it — struct, constructor, Node impl and all:
use horus::prelude::*;
node! {
Controller {
name: "controller",
sub { scan: LaserScan -> "scan" }
pub { cmd: CmdVel -> "cmd_vel" }
tick {
let Some(scan) = self.scan.recv() else { return };
let linear = if scan.ranges[0] > 0.5 { 0.3 } else { 0.0 };
self.cmd.send(CmdVel::new(linear, 0.0));
}
}
}
fn main() -> Result<()> {
let mut sched = Scheduler::new().tick_rate(100_u64.hz());
sched.add(Controller::new()).order(0).build()?;
sched.run()
}
That is the closest equivalent to the lambda form the C++ page shows. See the node! Macro Guide for the full section list.
No Arc<Mutex<_>>
This is where a ROS2 Rust node actually gets expensive to write. A subscription callback is a 'static closure, so any state it touches has to outlive the call — which in practice means Arc<Mutex<State>>, cloned into every callback, locked on every message, and deadlockable if two callbacks take two locks in different orders. Publishers arrive as Arc<Publisher<T>> for the same reason.
HORUS has no callbacks, so there is nothing to share. tick(&mut self) gets exclusive access to the node's fields by construction; the borrow checker enforces it at compile time and no lock is taken at runtime. Topic<T> is a value your struct owns and drops.
No IDL / .msg Files
ROS2 requires .msg files plus rosidl codegen, and under rclrs that codegen is a colcon build that produces the crates you then depend on. HORUS messages are plain Rust structs with #[repr(C)] layout — the same struct in Rust, C++ and Python, with no codegen step. Your own messages come from the message! macro:
use horus::prelude::*;
message! {
#[fixed]
/// Wheel encoder counts, published at 1 kHz.
WheelTicks {
left: i32,
right: i32,
stamp_ns: u64,
}
}
fn main() -> Result<()> {
let ticks = WheelTicks::topic("wheel.ticks")?;
ticks.send(WheelTicks {
left: 1200,
right: 1198,
stamp_ns: 0,
});
Ok(())
}
#[fixed] emits the struct as #[repr(C)] and Copy, which is what puts it on the zero-copy fast path, and Type::topic(name) checks the message's layout hash when the topic is opened — so two builds that reordered fields fail to connect instead of silently swapping values. Note the consequence for the standard messages too: HORUS LaserScan is a fixed [f32; 360] array rather than a Vec<f32>, because a shared-memory message cannot own a heap allocation.
Zero-Copy IPC
rclrs sits on rcl/rmw, so it pays the same DDS serialization and middleware hop as rclcpp. HORUS publishes straight into a shared-memory ring with no middleware in between: send() copies the message into the ring slot — no serialization, no broker — and pool-backed payloads (Image, PointCloud, DepthImage, alloc_tensor()) keep the bytes in the pool and put only a descriptor through the ring, so those are never copied at all. See Topics & Communication for the full Topic<T> reference.
Deterministic Scheduling
ROS2 uses callback queues whose ordering depends on the executor, the arrival order of messages, and the number of threads. HORUS gives you explicit .order() plus .deterministic(true), which keeps every node on the main tick loop and runs them sequentially in that order:
use horus::prelude::*;
struct Estimator;
impl Node for Estimator {
fn name(&self) -> &str { "estimator" }
fn tick(&mut self) {}
}
struct Planner;
impl Node for Planner {
fn name(&self) -> &str { "planner" }
fn tick(&mut self) {}
fn enter_safe_state(&mut self) {
hlog!(error, "planner safed");
}
}
fn main() -> Result<()> {
let mut sched = Scheduler::new()
.tick_rate(100_u64.hz())
.name("robot")
.deterministic(true);
sched.add(Estimator).order(0).build()?;
sched
.add(Planner)
.order(1)
.budget(5_u64.ms())
.on_miss(Miss::SafeMode)
.build()?;
println!("nodes: {:?}", sched.node_list());
sched.run_for(3_u64.secs())
}
Without deterministic(true), .budget() promotes a node to a real-time node that the scheduler hands to its own executor thread, and .order() then only sorts nodes within an executor.
Migration Checklist
- Replace the
create_node+ closure setup with a struct that implementsNode(or anode!block) - Replace
create_publisher::<T>withTopic::<T>::new(name)? - Replace
create_subscription::<T, _>with the sameTopic<T>— one type does both directions - Move subscription-closure bodies into
tick(&mut self), driven byrecv() - Replace
Arc<Mutex<State>>with plain fields on your node struct - Replace the colcon-generated message crates with
horus::preludetypes, ormessage!for your own - Replace
Context::new/rclrs::spinwithScheduler::new()/sched.run() - Replace
package.xml+cargo-ament-build+ colcon withCargo.toml+horus.toml - Replace
ros2 launchwithhorus launch - Replace
ros2 topic echowithhorus topic echo
Performance
There is no FFI layer to account for on this page: HORUS is written in Rust, so tick() is a direct call from the scheduler and Topic::send writes into the shared-memory ring from your own thread. The ~11-21 ns per-call binding overhead the C++ page quotes has no counterpart here — there is nothing to cross. rclrs, by contrast, is a wrapper over rcl, so it pays the C layer and the DDS serialization underneath it (~1-5 µs).
The project's published latency figures are being regenerated and carry no numbers at the moment: the build that produced them filtered outliers before computing p99/p99.9, so every tail column was bounded by construction rather than by anything the system did, and the intra-process table named backends that no longer exist. Rather than repeat numbers that no longer stand, measure the paths you actually use, from the root of a HORUS source checkout:
sudo cpupower frequency-set -g performance
cargo run --release -p horus_benchmarks --bin all_paths_latency -- --json results.json
cargo run --release -p horus_benchmarks --bin robotics_messages_benchmark
Read the scenario and backend columns the binaries print rather than comparing rows across regimes. The same-process [send] scenarios time send() alone and are not pub-to-sub delivery latency; the CrossProc-* scenarios embed an RDTSC timestamp in the payload for a true one-way cross-process measurement; and RawAtomic is the hardware floor for the same message on the same machine, which no cross-process transport can beat. Figures are hardware-dependent in any case, so a number measured on someone else's laptop is not a budget for yours.
Build in release when you measure anything: a debug build is ~50 µs per tick against ~500 ns for the same code in release, which is large enough to swamp whatever you are trying to measure.
horus run --release
See Performance Optimization for the full measurement table and methodology.
Next Steps
- Tutorial 1: IMU Sensor Node (Rust) — the publish/subscribe pattern from scratch
- Tutorial 2: Motor Controller (Rust) — budgets,
enter_safe_state()and closed-loop control - Tutorial 3: Full Robot System (Rust) — six nodes, parameters and transforms in one scheduler
- Topics & Communication — full
Topic<T>reference