Skip to content

Rust SDK

The omniloop crate is the safe Rust API over the same engine the C++ SDK wraps. If your control loop is Rust — a ros2-rust node, a Dora or Copper operator, a bare-metal-adjacent daemon — this is the binding to use.

[dependencies]
omniloop = "0.1"
use omniloop::{Config, HaltPolicy};
use std::time::Duration;
let mut lp = Config::new()
.halt_policy(HaltPolicy::Handoff) // never stop actuating
.halt_timeout(Duration::from_secs(5)) // deadman on a lost operator
.publish_every(10) // 1 kHz loop, 100 Hz frames
.journal("run.omni")
.journal_rotate_bytes(256 * 1024 * 1024)
.open()?;
// Live sliders. The handle *is* the storage.
let kp = lp.tunable_in("kp", 120.0, 0.0..=500.0, "Gains")?;
let kd = lp.tunable_in("kd", 12.0, 0.0..=50.0, "Gains")?;
// Hard envelopes — enforced in-process, unlike a slider's range.
lp.limit("kp", 0.0..=400.0)?;
// Measured values: published and watchable, unwritable from outside.
let err = lp.readout("tracking_error", 0.0)?;
let joints = lp.readout_array_in("joint_torque", 6, "Joints")?;
// Tripwires.
lp.watch_nonfinite()?;
lp.watch_array_within("joint_torque", -100.0..=100.0)?;
lp.deadline(Duration::from_micros(1000))?;
while running {
lp.step(
|| controller.update(dt), // normal body
|| damping.hold(dt), // fallback while halted
);
}

step is the safe-halt contract in one call: it opens the tick, asks the barrier what to do, runs the body or the fallback accordingly, converts an escaped panic into a journaled fault that hands over rather than dropping the machine, and closes the tick either way.

This is the one place the Rust SDK deliberately differs from the C++ one.

The C++ SDK binds a pointer to your variable and writes through it when an operator moves a slider. In Rust that is precisely what the aliasing rules forbid: a &mut f64 held by the loop for its whole lifetime would make the variable untouchable by your own code, and a raw *mut f64 would push unsafe onto every caller. So the storage belongs to OmniLoop and you get a handle.

let kp = lp.tunable("kp", 120.0, 0.0..=500.0)?;
let torque = kp.get() * err; // the operator's latest value
kp.set(90.0); // or drive it from code

Three properties fall out, and all three are improvements:

  • Handles are independent of the &mut Loop borrow. A tick can be open while your body reads and writes state. With a pointer binding, the borrow checker would have made the tick and the body mutually exclusive.
  • Clone, so a controller struct can hold its own copies rather than threading references through call sites.
  • A dangling binding is impossible to express. That is the one real hazard in the C++ SDK, and here it does not exist: the loop owns the storage and outlives every handle by construction.
Call Returns Writable by the control plane? Visible to watchpoints?
tunable(name, initial, range) Tunable yes, clamped by any limit yes
toggle(name, initial) Tunable yes yes
readout(name, initial) Readout no yes
readout_array(prefix, n) ReadoutArray no yes

Reads and writes are Cell accesses — no atomics, no locks, no allocation.

Loop, Tunable, Readout and ReadoutArray are all !Send. A control loop and the state it owns live on one thread, and the compiler may as well enforce it. This is a compile-time guarantee, not a convention:

fn needs_send<T: Send>(_: T) {}
needs_send(lp); // error: `Rc<()>` cannot be sent between threads safely

If you need to hand values to another thread, copy them out (kp.get()) and send the f64.

Robot state is vectors, and naming elements one at a time is the boilerplate that gets skipped — which means the tripwire that would have caught the fault was never armed.

let joints = lp.readout_array("joint_pos", 6)?; // joint_pos_0 … joint_pos_5
joints.copy_from_slice(&positions);
lp.watch_array_within("joint_pos", -2.0..=2.0)?;

watch_array_within arms two group watchpoints — one per bound — not 2n scalar ones. The array shares a latch, so an excursion is one event rather than one per joint that wobbles at the same moment, and a trip still names the element. Bind the array first: a group matching nothing is an error.

Out-of-range writes on a ReadoutArray are ignored rather than panicking. An off-by-one in telemetry must not take down a control loop.

tick() and step() perform no heap allocation once warm, take no lock another thread holds for an unbounded time, and do no I/O on the calling thread. The engine is shared with the C++ SDK, so the full accounting applies unchanged. Two rules follow:

  1. Declare every channel before the first tick. A later one is refused, because it would resize the frame buffer on your control thread.
  2. Spin the loop a few hundred iterations before raising priority. The buffers settle during warm-up and then never grow.

Measured on the bundled example in release mode: 1031 Hz sustained with a per-tick cost in the hundreds of nanoseconds. As with every other figure in these docs, that is x86 desktop — there is still no published ARM-under-load characterization.

step wraps the body in catch_unwind. A panic becomes a journaled freeze_on_exception, a black-box dump, and a halt with reason exception — so on a Handoff loop the next iteration runs your fallback rather than the process dying with the machine in the air.

This works only under panic = "unwind" (the default). With panic = "abort" the process dies before OmniLoop sees anything, which is a legitimate choice for some systems — just not one that leaves room for a fallback controller.

If you would rather handle errors yourself, use tick() and branch on run_safety():

let t = lp.tick();
if t.run_safety() {
damping.hold(dt);
} else if let Err(e) = controller.try_update(dt) {
drop(t);
lp.report_fault(&e.to_string());
}

Re-execute a recorded journal and check that the loop reproduces it:

let ticks = lp.attach_replay("run.omni")?;
for _ in 0..ticks {
lp.step_body(|| controller.update(dt)); // the same controller
}
match lp.replay_summary().verdict() {
"verified" => println!("reproduces"),
other => println!("{other}: {:?}", lp.replay_summary().first_divergence),
}

Each tick, the mutations the original run received are fed back in place of whatever is on the command ring — a live edit cannot steer a replay — and each recorded hash checkpoint is compared against the replayed one.

Read verdict(), not the divergence count. When every checkpoint disagrees the answer is cannot_verify, not “diverged at tick 1”: that pattern means the comparison itself is invalid — a different program, a different hash rule, or a loop that is not reproducible at all — and someone told “diverged at tick 1” will confidently debug a bug that does not exist.

This verifies determinism; it cannot create it. A loop that reads the clock, consumes unseeded randomness, or depends on thread scheduling will diverge — and the divergence is the finding.

A Rust loop is indistinguishable from a Python or C++ one to everything downstream. Verified: journals written by this SDK read back cleanly through the Python CLI.

Terminal window
omniloop inspect run.omni
omniloop why run.omni --tick 819
omniloop export-mcap run.omni

Every frame carries __sys_sdk__ naming the binding that produced it, so a trace says which SDK wrote it.

Gap Notes
Framework adapters No ros2-rust / Dora / Copper integrations. A Rust loop declares its own channels, which is usually what you want.
Non-f64 channels Every channel is an f64; a flag is an f64 compared against 0.5.
Multi-process aggregation One publisher per session, as elsewhere.