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"A complete controller
Section titled “A complete controller”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.
Handles, and why they are not &mut f64
Section titled “Handles, and why they are not &mut f64”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 valuekp.set(90.0); // or drive it from codeThree properties fall out, and all three are improvements:
- Handles are independent of the
&mut Loopborrow. 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.
!Send, on purpose
Section titled “!Send, on purpose”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 safelyIf you need to hand values to another thread, copy them out (kp.get()) and send
the f64.
Vectors
Section titled “Vectors”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_5joints.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.
Real-time notes
Section titled “Real-time notes”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:
- Declare every channel before the first tick. A later one is refused, because it would resize the frame buffer on your control thread.
- 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.
Panics
Section titled “Panics”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());}Replay
Section titled “Replay”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.
Interoperability
Section titled “Interoperability”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.
omniloop inspect run.omniomniloop why run.omni --tick 819omniloop export-mcap run.omniEvery frame carries __sys_sdk__ naming the binding that produced it, so a trace
says which SDK wrote it.
What is not implemented
Section titled “What is not implemented”| 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. |
Related
Section titled “Related”- Halt & Step — the safe-halt contract in full.
- C++ SDK — the same engine, C ABI and C++17 header.
- Limit Enforcement — how envelopes are enforced.