Migrating from ROS2 (Rust)

Prefer another language? The same guide exists for Python and C++.

This guide shows ROS2 rclrs patterns and their HORUS equivalents side by side.

⚠️ROS2's Rust client library is a community project

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

ConceptROS2 rclrsHORUS Rust
Noderclrs::create_node(&ctx, "name")impl Node for YourStruct
Publishernode.create_publisher::<T>(topic, qos)?Topic::<T>::new(topic)?
Subscribernode.create_subscription::<T, _>(topic, qos, cb)?the same Topic<T>
Callbackclosure passed to create_subscriptionfn tick(&mut self) calling recv()
Node stateArc<Mutex<State>> captured by the closureplain fields behind &mut self
HandlesArc<Node>, Arc<Publisher<T>>value types owned by your struct
Messagesensor_msgs::msg::LaserScan (generated crate)LaserScan from horus::prelude
Message layoutranges: Vec<f32> — heap allocatedranges: [f32; 360]#[repr(C)], no allocation
Publishpublisher.publish(msg)? — fallible, serializestopic.send(msg) — infallible, writes the struct
Receivecallback-drivenpoll: topic.recv()Option<T>
InitContext::new(env::args())?nothing needed
Runrclrs::spin(&node)?sched.run()
Ratecreate_wall_timer or a manual sleep loop.tick_rate(100_u64.hz()), per-node .rate()
QoSQOS_PROFILE_DEFAULT.budget(5_u64.ms()).on_miss(Miss::Skip)
Buildcolcon + cargo-ament-build + package.xmlcargo + 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

  1. Replace the create_node + closure setup with a struct that implements Node (or a node! block)
  2. Replace create_publisher::<T> with Topic::<T>::new(name)?
  3. Replace create_subscription::<T, _> with the same Topic<T> — one type does both directions
  4. Move subscription-closure bodies into tick(&mut self), driven by recv()
  5. Replace Arc<Mutex<State>> with plain fields on your node struct
  6. Replace the colcon-generated message crates with horus::prelude types, or message! for your own
  7. Replace Context::new / rclrs::spin with Scheduler::new() / sched.run()
  8. Replace package.xml + cargo-ament-build + colcon with Cargo.toml + horus.toml
  9. Replace ros2 launch with horus launch
  10. Replace ros2 topic echo with horus 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