The Rust follower stack,
taken by the hand
How one teleop setpoint, one robot command, and one telemetry frame travel through the ported system — and every Python idiosyncrasy that deliberately survived the trip. The port's contract was: the Python implementation is the specification. Where Python looked buggy, we ported the bug and filed it (PORTING.md §13.4) instead of silently fixing it. This document is the mental model you need before we start un-quirking it.
How to read this document. Three walkthroughs cover the three flows; the quirk catalog after them
is the part that will save you from "fixing" something incident-derived. Every claim carries a
file:line reference into the current rust branch. Two vocabularies to keep apart:
Sacred quirks (§8) — incident-derived behavior; changing one is a correctness bug.
Ported bugs (§13.4) — Python oddities we reproduced on purpose; these are the post-validation cleanup backlog.
1 · Topology: three processes, two cities
The old world was one big Python process. The new world: the operator PC keeps the unchanged Python
orchestrator, frontend and SpaceMouse leader; one new adapter (remote_follower_bridge.py) maps the
in-process bus onto the network; and a self-contained Rust robot-node runs next to the arm.
Everything that must be delivered rides gRPC; the two high-rate latest-wins streams (actions in,
state out) ride UDP.
flowchart LR
subgraph OP["Operator PC — Python, unchanged"]
SM["SpaceMouse leader
100 Hz admittance"] -->|"state.leader"| ORCH["Orchestrator
mirrors in TELEOPERATION"]
ORCH -->|"action.* topic"| BR["remote_follower_bridge.py"]
ORCH -->|"cmd.* (corr, reply_to)"| BR
BR -->|"state.* / event.* / bridge.*"| FE["frontend · profiler · TraceLogger"]
end
subgraph NODE["mini PC — robot-node (Rust)"]
AL["action_listener thread
SeqGate"] --> CORE["NodeCore"]
GRPC_S["tonic control plane (tokio)
Command / StreamActions / StreamEvents"] --> CORE
CORE --> MB["driver mailbox
depth-1, latest-wins"]
MB --> SJ["ServoJ sender thread
guard suite on every frame"]
CN["CNDE reader thread"] --> TEL["publish_worker thread"]
RP["UDP reply thread
rejection = e-stop"]
end
BR -->|"UDP S7A1 actions :50063"| AL
BR <-->|"gRPC :50061"| GRPC_S
TEL -->|"UDP S7N1 telemetry"| BR
SJ -->|"ServoJ :20007"| CTRL["FR3 controller"]
CTRL -->|"CNDE state :20005"| CN
CTRL -->|"reject replies"| RP
CORE -->|"XML-RPC :20003
postures · e-stop · IK"| CTRL
Who owns which thread
The one place the port is not line-for-line: Python leaned on threads hidden inside the vendor SDK; the Rust SDK is deliberately synchronous, so the driver owns them explicitly. The paced hot loops stay dedicated OS threads — tokio exists only for the gRPC control plane, so an e-stop RPC never contends with action parsing for an async worker.
| Thread | Layer | Spawn | Pacing | Job |
|---|---|---|---|---|
| action_listener_{id} | node | actions.rs:257 | blocking recv, 200 ms timeout | UDP S7A1 in → SeqGate → ingest_action |
| publish_worker_{id} | node | telemetry.rs:223 | Instant-paced @ publish_rate_hz | get_state → S7N1 frame out; drains event + trace queues |
| tonic runtime (tokio) | node | main.rs:242 | async | Command / StreamActions / StreamEvents; each command on spawn_blocking |
| CNDE reader | driver | robot.rs:1105 | blocking next_state() | controller state → RwLock snapshot + first-frame latch |
| ServoJ sender | driver | robot.rs:1078 | Instant grid @ cmdT | mailbox → guards → cap_delta → ServoJ; hold on starvation |
| UDP reply reader | driver | robot.rs:1152 | blocking recv, 100 ms timeout | controller rejection verdicts → e-stop (never an RPC!) |
| reconnect_{id} | node, ad-hoc | node.rs:379 | one-shot | fire-and-forget disconnect → 0.5 s → connect; joined only at shutdown |
| _supervisor / _udp_listener / _health_loop | bridge (py) | remote_follower_bridge.py:642-652 | daemons | link + events relay · telemetry in · health/RTT probe |
Locks that replaced the GIL (robot.rs:635-668, sync.rs)
sdk Mutex — the old _sdk_lock; held across multi-call RPC sequences.
snapshot RwLock<Option<CndeState>> — single writer (CNDE thread).
sdk_state Mutex — two-owner discipline: CNDE thread writes fairino_state, engage path writes probe_state, never the same field.
Plus status, safety, postures, and the mailbox.
Sanctioned nesting only: safety → {status, sdk_state, stream, sdk, fk} (a guard trip fires e-stop while the safety mutex is held) and postures → sdk. The reply thread takes no RPC lock, ever.
Ports cheat-sheet
Operator ↔ node: gRPC :50061 · telemetry UDP :50062 · actions UDP :50063 (defaults; remote_node: block in the robot yaml).
Node ↔ controller: XML-RPC :20003 · CNDE realtime state :20005 · ServoJ setpoints UDP :20007.
Wire magics: actions "S7A1", telemetry "S7N1" — cross-flow datagrams can never parse.
2 · Walkthrough A — life of a teleop setpoint
The 100 Hz hot path. Follow one joint-position setpoint from the SpaceMouse to the arm's servo loop. The single most important object on this path is the depth-1 latest-wins mailbox — a fresher setpoint always supersedes a delayed one, which is why UDP loss and reordering are absorbed by design.
flowchart TD A["1-2 · leader state, mirrored by orchestrator
action.* on the bus (JSON)"] --> B["3-4 · bridge: dict to protobuf,
S7A1 datagram, seq++"] B --> C["5 · node listener: decode + SeqGate
stale dropped, e-stop never"] C --> D["6 · NodeCore.apply_action
hop stamps + command_latency_ms"] D --> E["7-8 · driver mailbox put
depth-1, latest-wins, read-without-take"] E --> F["9-10 · sender tick at cmdT
fresh? dequeue : hold"] F --> G["11 · guard suite — every frame
trip = e-stop"] G --> H["12 · cap_delta vs measured,
ServoJ UDP :20007"] H -.->|"controller replies ONLY on rejection"| I["13 · reply thread verdict
reject = e-stop"] F -.->|"producer late"| J["14 · hold last frame forever
(halt window optional, never de-energize)"]
Operator side (Python, unchanged except the bridge)
Step 1-2 · Leader → orchestrator mirror → action.{id}
The SpaceMouse leader's admittance thread builds a RobotState at 100 Hz and stamps
leader_timestamp = now (spacemouse_leader.py:316-329). The orchestrator,
only in TELEOPERATION mode, mirrors it to the follower: joints copied 1:1, velocities/efforts zeroed
(orchestrator_node.py:342 → 383 → 414), published as RobotState.to_dict()
JSON on action.{id}. Nothing here changed in the port — that is the zero-orchestrator-changes property.
Step 3 · Bridge ingress: _on_action → _put_action
The bridge subscribes action.{id}, converts the bus dict into the wire protobuf
Action (typed hot fields + everything else squeezed into rest_json), and routes it.
Two things to notice: actions are only forwarded while the stream is engaged (PREPARE_STREAM engages,
ENTER_IDLE disengages), and an e-stop action short-circuits into the dual-path sender before any of that
(remote_follower_bridge.py:734-746).
Step 4 · The S7A1 datagram
One datagram = one action. Big-endian 11-byte header, then the robot id, then the protobuf. The sender owns one process-wide wrap-aware u32 sequence counter that survives engage cycles (remote_follower_bridge.py:467-478; frame codec mirrored in rust frame.rs:30/43).
Telemetry uses the same layout under magic "S7N1" with a JSON payload — the differing magic is what makes cross-flow parsing impossible.
Node ingress (Rust, dedicated OS thread)
Step 5 · Listener thread: decode, hostile-datagram policy, SeqGate
A dedicated OS thread (not tokio — a datagram burst must never starve the control plane) blocks on the
socket with a 200 ms timeout. Garbage, wrong magic/version, or undecodable protobuf → counted as
malformed and ignored, never a fault. A foreign robot_id is dropped silently and
uncounted (it is another flow's traffic). Then the seq gate (actions.rs:341, 185):
decode (actions.rs:368), not the gate. A stale e-stop is still ingested, but accept never stores its seq — so a reordered e-stop cannot reopen the window for the setpoints behind it. Duplicates are fine: the driver's e-stop latch is idempotent.
Step 6 · NodeCore::apply_action — hop stamps and the latency number
Transport-neutral from here down — the gRPC StreamActions fallback feeds the exact same function.
The action is rebuilt into a RobotState (rest_json first, typed fields overlaid), then
stamped through the follower.{id}.* hop chain the profiler renders. command_latency_ms
is wall-clock now − leader_timestamp — which is why it assumes chrony across hosts.
Any failure in this function (reconstruction, panic, driver error) is answered with an e-stop, not a log line
(node.rs:519, 552).
Driver: mailbox → paced sender → guards → hardware
Step 7 · set_state_by_ref — producer entry, never a sender
Ingress does not validate or send. It type-checks, records the commanded pose, short-circuits an
e-stop straight into emergency_stop_impl (so it can never wait behind the send cadence),
stamps servoj_enqueue, and overwrites the mailbox (robot.rs:1453-1468).
Step 8 · The mailbox: depth-1, latest-wins, read-without-take
Python got this for free — "reference assignment is atomic under the GIL" — so Rust makes it a
Mutex<Option<CommandSlot>> with very particular semantics
(stream.rs:181, 200-209, 245-251):
- The producer overwrites; the consumer clones and leaves the slot in place. It is cleared only by
StreamHealth::reset()— never by the tick. - Staleness comes from
t_mono: a slot re-read inside the stale window is a fresh re-send of the same setpoint, not a hold. A take-semantics port would silently flip starvation accounting and the flight-recorder record kind (pinned by testre_read_within_window_is_fresh_not_hold, stream.rs:483). - No queue may ever sit in front of this mailbox — buffering stale setpoints recreates the exact failure it exists to prevent. (The post-merge playout-queue project deliberately bounds + age-caps for this reason.)
Step 9 · The sender thread: an Instant grid at cmdT
cmdT = 1 / publish_rate_hz (10 ms at 100 Hz) and is never configured independently —
the controller computes joint speed as delta/cmdT, so a mismatch inflates implied speed
(stream.rs:44-54, robot.rs:727). The loop gates on ProbeState::Ready
exactly (not != Off) so the readiness probe owns the channel alone during SETTLING. A tick error
kills the thread — mirroring an uncaught exception in Python's daemon loop (robot.rs:1231-1244).
Step 10 · One tick: tick_paced
The M6 core (send_frame) decides fresh-vs-hold and injects the guarded send as a closure.
servoj_dequeue stamps and profiler chain exports fire only on fresh frames; holds export
nothing (robot.rs:1259-1289).
Step 11 · The guard suite — every frame that reaches hardware
Sacred §8.8: fresh, hold, or synchronous — every frame passes validate_command. Hold frames
are not exempt. Command-path order, first failure short-circuits
(envelope.rs:239-270):
joint_speed and eef_speed are state-side observers (observe_state,
envelope.rs:284) — they watch the measured stream, not commands.
Trip-lock: N consecutive violations latch tripped and fire the robot's e-stop callback once
(guard.rs:213-228); only reset_after_estop_release clears the latch.
Trip counts differ by config block: 3 for command_guard/joint_speed, 1 for command_speed/eef_speed/workarea
(config.rs:479-484). On a trip inside guarded_send the driver
e-stops and refuses the write (robot.rs:1474-1485).
Step 12 · set_state_impl: cap_delta, the sdk lock, and the wire
The last gates before hardware (robot.rs:1555-1597): e-stop bypass → latch gate →
joint-count gate → streaming-ready gate → cap_delta → hop stamps around the sdk lock
(command_pre_lock / command_lock_acquired) → record_hardware_tick →
send_servoj (UDP passthrough, fire-and-forget). A transient RPC error drops the setpoint with a
warning — never a crash.
cap_delta (sacred §8.10) (robot.rs:429-488): closes the loop against the
measured pose, returns the target unchanged when within the per-joint step, and is a no-op when
unconfigured or before the first observation. Its rate-limit warning is edge-triggered — once per ramp
episode, via an AtomicBool.
The return leg and the failure paths
Step 13 · The controller only speaks when it rejects
ServoJ over UDP has no acks. A dedicated thread blocks on the reply socket (polling at cmdT would add up
to one tick of e-stop latency). The verdict is a three-way match on
(errcode, probe_state, status) — and this thread reads only the packet snapshot, never an
XML-RPC (deadlock rule, sacred §8.12) (robot.rs:1737-1763).
Step 14 · Starvation: hold forever, never de-energize
When the producer goes quiet the sender repeats the last frame (or freezes at the measured pose if nothing was
ever sent), warning at a 2 s cadence (stream.rs:265-325, 373-394).
Sacred §8.9: servoj_halt_after_ms = 0 (default) means hold forever; a positive
window exits the stream phase — arm held — but never de-energizes. Link loss must not drop a payload.
stateDiagram-v2
[*] --> FRESH
FRESH --> FRESH : new slot, or re-read inside stale window (fresh re-send)
FRESH --> HOLD : slot dwell exceeds stale window
HOLD --> HOLD : repeat last_sent (guards still run) — warn every 2 s
HOLD --> FRESH : producer resumes
HOLD --> HALTED : halt_after_ms greater than 0 and gap reached
HALTED --> [*] : exit stream phase — arm held, motors stay energized
3 · Walkthrough B — life of a robot command
Everything that must be delivered — mode changes, resets, IK solves — rides the reliable gRPC unary
Command. The Python RobotNode._command_handlers table survives 1:1.
Step 1 · Bus request with corr
CommandRouter.request mints a corr from a counter and publishes
{action, params, reply_to, corr} on cmd.{id}; replies demux by corr on one
persistent subscription (command_router.py:155-199). Unchanged from the
in-process days.
Step 2 · Bridge forwards — and never retries
_on_robot_command makes the unary RPC. On transport failure it publishes the F4 error
reply {corr, value: null} so the requester fails fast instead of eating its timeout
(remote_follower_bridge.py:1035-1049). PREPARE_STREAM / ENTER_IDLE also flip the
bridge's action-stream engage flag (:1064-1066).
Step 3 · Node: one spawn_blocking per command
Handlers may block on the controller, so each command gets its own blocking task — an e-stop command
never queues behind the action stream (they share nothing queue-like). The reply carries
node_recv_mono/node_send_mono, sampled before dispatch and just before send
(grpc.rs:233-248) — the raw material for clock-offset estimation.
Step 4 · Dispatch guards, panic = Python exception
dispatch: running check → param parse → twin-command rejection → catch_unwind.
A handler panic (the ported ValueErrors) becomes Dispatch::Failed(panic_message)
with the same log text — per-call exception isolation, exactly like Python
(node.rs:272-298).
The 16-command table (node.rs:307-326)
| Wire name | Driver call | Returns | Notes |
|---|---|---|---|
| prepare_stream | prepare_command_stream() | Bool | posture walk + probe; refuses while e-stop latched |
| enter_idle | enter_idle() | Bool | ManualIdle walk; no-op (true) while latched |
| reset_emergency | reset_from_emergency_stop() | Bool | the only path that clears the latch |
| release_safety | safety_reset_after_estop_release() | Null | clears every guard's trip latch |
| set_guard_enabled | safety_set_guard_enabled(g, on) | Null | + DANGEROUS audit warning |
| set_safety | safety_update_runtime(block, payload) | Null | ports the apply-as-you-go coercion order |
| reconnect | spawns reconnect_{id} thread | Null (immediately) | disconnect → 0.5 s → connect; fire-and-forget |
| teleop_enter / teleop_exit | on_teleop_enter/exit_leader() | Bool / Null | enter is a logged no-op (leader-only concept) |
| set_teleop_guidance | set_teleop_guidance(mode) | Bool | logs unsupported — drag was cut (§12) |
| level_tcp | level_tcp_target() | joints | Null | |
| orient_primitive | orient_primitive_target(p) | joints | Null | DLS solve in kin.rs/orient.rs since 2026-07-22 |
| solve_ik | solve_ik(pose, ref) | joints | Null | TCP-pose + IK held under one sdk lock |
| set_vacuum | set_vacuum(on) | Bool | trait default false = "no capability", silent |
| set_compression_active | flips node atomic | Null | replaces Python's 10 Hz flag poll; suppresses deadline warnings |
| set_trace_active | trace_set_enabled(on) | Null | node-control, not orchestrator vocabulary |
| enable_digital_twin · snap_twin_anchor | — | UNSUPPORTED | twin is operator-side visualization now |
| anything else | — | UNKNOWN | bridge publishes no bus reply (same as Python) |
Worked example: PREPARE_STREAM (how the arm becomes streamable)
- Refuse if latched (robot.rs:1814-1818) — sacred §8.7: recovery is only ever the explicit reset path.
- Posture walk to TELEOP_STREAM (posture.rs:234-266) — the same ladder every time, no controller-truth polling:
return_to_manual(close servo window → Mode Manual → RobotEnable(1)) →enter_automatic(Mode Auto → RobotEnable(1)) → open the servo window. Each controller call runs the ported retry: retry only on codes in the set,ResetAllErrorre-issued before each attempt whenclear_first(posture.rs:314-348).ServoMoveStartretries NOT_READY 6× / 0.3 s because RobotEnable returns before the servos energize (posture.rs:354-367). - Close is belt-and-braces:
close_servo_windowalways attempts and keeps no "is it open" flag — a cached guess once turned a failed close (err=14) into a runaway (posture.rs:371-392). - The probe (robot.rs:1847-1908) — sacred §8.3: the controller replies to ServoJ only on rejection, so send one probe frame, wait the 0.2 s quiet window; silence is the accept. One probe in flight at a time; rejections during SETTLING mean "keep probing", never e-stop; 3 s of failures returns
falseinstead of raising (Tenacity'sretry_error_callback, hand-ported).
stateDiagram-v2
OFF --> SETTLING : prepare_stream — posture walk done
SETTLING --> SETTLING : ServoJ rejected — keep probing
SETTLING --> READY : 0.2 s of silence = accept
SETTLING --> OFF : 3 s exhausted — prepare returns false
READY --> OFF : e-stop / halt window / enter_idle
note right of READY : quirk — disconnect() never exits the stream phase, so READY survives teardown (harmless only because reconnect re-walks + re-probes)
Worked example: EMERGENCY_STOP (three triggers, one funnel)
Three independent triggers all funnel into emergency_stop_impl: (a) an operator e-stop action —
dual-pathed by the bridge as a UDP datagram and a one-shot reliable
StreamActions call with wait_for_ready, so a 100% UDP blackhole still delivers at
gRPC speed (remote_follower_bridge.py:888-930); (b) a guard trip inside
guarded_send; (c) a controller ServoJ rejection on the reply thread. Duplicates collapse into one
latch. The orchestrator keeps re-firing e-stop states at 100 Hz while the leader is latched — by design, the
latch makes that idempotent.
Recovery is strictly ordered and the ordering is enforced by the latch gates, not by convention:
prepare_stream and enter_idle both short-circuit while latched, so the only way
forward is reset_emergency (refuses unless latched → clears every guard trip → status Idle →
ResetAllError) → enter_idle → prepare_stream
(robot.rs:1679-1706). After any stream gap the leader must re-sync targets
to the follower's measured pose first — resuming onto phase-advanced targets trips max_joint_deltas
straight back into e-stop (N-D F3: the guard working as designed).
Measured cmd→RobotEnable(0) latencies (N-D): 48.5 ms median @ 50 ms RTT ·
166.7 ms @ 150 ms · 150.9 ms @ 5% loss (p99 308 ms) · 47.3 ms around a hard link cut.
Node-internal apply ≤ 0.7 ms — the wire is the entire latency. The hardware E-stop remains the real safety layer.
The reply leg doubles as a clock
sequenceDiagram
participant B as Bridge (operator PC)
participant N as Node (mini PC)
B->>N: Command RPC — t0 = perf_counter()
Note over N: t1 = node_recv_mono (before dispatch)
Note over N: handler runs on spawn_blocking
Note over N: t2 = node_send_mono (just before send)
N-->>B: CommandReply {corr, value, t1, t2}
Note over B: t3 = perf_counter()
Note over B: offset = ((t1-t0)+(t2-t3))/2, uncertainty = RTT/2
Every command reply feeds one four-timestamp sample (remote_follower_bridge.py:1025-1033, clock_offset.py:44-51). The estimator keeps a window of 8, picks the min-RTT winner, and blends with a slow EWMA (α = 0.1). No clock is ever adjusted — estimation only, published with its uncertainty on bridge.{id}, reset on link loss (a restarted node restarts its monotonic epoch).
This is what lets the profiler translate node-side hop times onto the operator timeline (Walkthrough C, step 6). Wall-clock fields (leader_timestamp, command_latency_ms) still assume chrony — never trust sub-10 ms cross-host wall deltas.
4 · Walkthrough C — telemetry, events, and the trace backfeed
Three return channels with three delivery guarantees: lossy latest-wins UDP for the 100 Hz state stream, a reliable priority gRPC lane for robot events (an EMERGENCY_STOP notification must never be lost), and a capped bulk lane for diagnostics that must never delay the priority lane.
Step 1 · CNDE reader: the snapshot and the first-frame latch
The reader blocks in next_state(), writes each frame into the RwLock snapshot, and latches
first_frame_seen (robot.rs:1127-1147). Sacred §8.11: every state
read fails with BadResponse until that latch — a pre-sync seeded from an all-zero packet would
jump the arm. (Python checked frame_cnt == 0 && joints all zero; the FR firmware rejects
frame_cnt at CNDE config time, so the mechanism changed, the observable behavior did not.)
Step 2 · Reading a state: classify, demux, and two famous lies
read_state_from_pkg maps the packet to RobotState (joint arrays truncate with
take — slice semantics, sim packets are really shorter) (state.rs:92-131).
The classifier is most-severe-wins — and ports the quirk that only EmergencyStop == 1 counts
as e-stop; any other non-zero value falls through (state.rs:151-169).
Vacuum state is a bit demux over both DO bytes (state.rs:181-188).
And yes: units ships as "radians" while the numbers are degrees — the dataclass default
was never set in Python, so it is never set in Rust either.
Step 3 · The paced publisher: S7N1 frames + the deadline theater
publish_worker_{id} ticks at publish_rate_hz on a never-resetting grid; each tick is
wrapped in catch_unwind so a ported-guard panic logs and the loop continues (Python's
per-iteration except) (telemetry.rs:274-331). Payload = the exact
RobotState::to_dict() JSON whose parity M1 proved — protobuf telemetry is a flagged later
optimization, one variable at a time.
Step 4 · Bridge receive: wrap-aware latest-wins, epoch reset
The bridge drops out-of-order frames by seq (same half-window test as the node's action gate), ages the epoch
out after 2 s of silence, resets it on link loss, and republishes the JSON payload unchanged on
state.{id} — the frontend cannot tell the follower moved cities
(remote_follower_bridge.py:1197-1225).
Step 5 · Events: two lanes, priority always drains first
Robot events (EMERGENCY_STOP, toggles) ride an unbounded priority channel; trace_batch
diagnostics ride a capped (8) bulk channel that drops-newest on overflow. The stream implementation
polls priority first, always — a trace batch can never delay or reorder an e-stop notification
(grpc.rs:104-146, 202).
Step 6 · The trace backfeed: the profiler works cross-host
On every fresh guarded send the driver exports the action's hop chain into a bounded queue
(try_send, drop-newest + counter — zero new locks on the sender tick)
(trace.rs:375-387). The telemetry thread drains chains (≤160) and hardware-send
wall stamps (≤200) every ~1 s into a trace_batch bulk event. Operator-side,
TraceBackfeed shifts node-monotonic hop times by −offset (the estimator from
Walkthrough B), drops and counts chains that go backwards in time (a wrong offset surfaces instead of
rendering), and injects them into the operator TraceLogger — zero frontend changes
(trace_backfeed.py:112-150).
One deliberate exception: hw_tick wall stamps are injected UNtranslated — the
consumer-rhythm panel diffs consecutive sends per robot, so a constant cross-host offset cancels; routing them
through the offset estimator would add error. Dedup is a strictly-increasing wall filter
(trace_backfeed.py:86-102).
publish.{id}.* hops (deliberately cut, telemetry.rs:14-22) — so telemetry frames arrive with empty hops. Action-path profiling is complete; publisher-path profiling is not.
5 · The quirk catalog
Two very different lists. The first is load-bearing: each row is incident-derived and changing it is a correctness bug, however odd it looks. The second is the cleanup backlog: Python bugs we knowingly reproduced (fixture-pinned, so un-quirking one means updating its fixture too).
5.1 · Sacred — do not "fix" (§8)
| # | Behavior | Why it exists | Rust home |
|---|---|---|---|
| 1 | Vendor SDK returns a bare int instead of (err, data) → BadResponse, treated transient, never a crash | real controller misbehavior | err.rs:165 |
| 2 | code_of: empty tuple → −1 | callers key on −1 | err.rs:117-125 |
| 3 | Probe: silence for 0.2 s is the accept; one in flight; SETTLING rejections keep probing, never e-stop | controller replies only on rejection | robot.rs:99, 1878, 1855 |
| 4 | cmdT = 1/publish_rate_hz, never an independent knob | controller computes speed = delta/cmdT; mismatch inflates implied speed | stream.rs:44-54 |
| 5 | XML-RPC cmdType for SimMachine is a static construction-time decision — no runtime errcode-14 detection | errcode-14 incident motivated it, once | robot.rs:88-93, 722-726 |
| 6 | E-stop = RobotEnable(0), never StopMotion; latch logs once, enable-off re-issued every call | 2026-06-18: StopMotion returned 0, arm kept driving | robot.rs:1640-1662 |
| 7 | prepare_stream / enter_idle refuse while latched — recovery only via explicit reset | no accidental re-arming | robot.rs:1814, 1923 |
| 8 | Every frame that reaches hardware runs the guard suite — holds are not exempt | a hold is still a command | robot.rs:1474 |
| 9 | Starvation holds forever by default; a positive halt window exits the stream phase but never de-energizes | de-energizing on link loss would drop a held payload | stream.rs:373-394 |
| 10 | cap_delta closes the loop against the measured pose; no-op unconfigured / pre-observation; warning edge-triggered per episode | ramp-in without oscillation | robot.rs:429-488 |
| 11 | State reads fail until the first real CNDE frame | an all-zero pre-sync would jump the arm | state.rs:97-104 |
| 12 | The reply thread reads only the packet snapshot — never an XML-RPC call | deadlock | robot.rs:31-35, fault_diag.rs:38-44 |
| 13 | The (errcode, probe, status) verdict match: 0 → ignore · SETTLING → flag · latched → nothing · else → e-stop | probe vs stream vs latched are different worlds | robot.rs:1743-1760 |
| 14 | Realtime-stream config failure is a warning, not fatal (re-homed onto the CNDE handshake); soft-limit fetch failure skips limits with a warning | degraded stream beats no stream | robot.rs:960-971, 1011 |
5.2 · Ported bugs & oddities — the future cleanup backlog (§13.4)
Badges: ported bug = faithful reproduction of a Python defect, fixture-pinned · deviation = deliberate, documented divergence from Python · rust-side wart = introduced by the port itself, not by Python.
State layer
ported bug The units lie. Every Fairino state ships units: "radians" (the dataclass default) while the driver publishes degrees. Nothing downstream reads it — yet. types.rs:487
ported bug Only EmergencyStop == 1 classifies as e-stop — a 2 falls through to less-severe verdicts. state.rs:151-169
deviation Negative do_port reads the vacuum bit as off where Python raised ValueError on the read path. Unreachable from real configs. state.rs:176-188
Safety layer
ported bug Tripped guards bounce e-stop frames. Delta/speed/state guards check tripped before their e-stop bypass; the workarea guard checks e-stop first. So "e-stop frames bypass the trip-lock" holds only until one of those four trips. max_joint_deltas_guard.rs:46-54 vs workarea_guard.rs:75-80
ported bug A bogus end_effector_link builds an EMPTY FK chain → available = true → the point is the base origin forever → the workarea guard silently checks (0,0,0) instead of failing closed. fk.rs:297-337, 251-266
ported bug Silent 65-hop chain truncation on very deep URDFs — chain rooted mid-tree, no error. fk.rs:305
ported bug set_enabled(false) clears the violation counter but not tripped — re-enabling a tripped guard keeps it tripped (probably deliberate; only reset_after_estop_release clears it). guard.rs:148-153
deviation Malformed config aborts startup where Python silently built no guard (truthy non-mapping guard block, string rates, explicit-null limits). Divergence is abort-vs-silence only — fail-closed. config.rs:559-573, policy.rs:147-158
Driver
ported bug disconnect never exits the stream phase — a READY probe state survives teardown. Harmless only because reconnect re-walks postures and re-probes. robot.rs:1054-1072
ported bug ERROR→CONNECTED is dead code on the success path — the next line overwrites with MOVING. (The recovery that matters lives on the read path.) robot.rs:1589-1595
ported bug E-stop with the interface down returns true even though nothing was de-energized — the latch still flips, so the passive gate holds. robot.rs:1654-1656
ported bug An error escaping a sender tick kills the ServoJ thread — in both implementations (Python: uncaught daemon exception; Rust: logged break). robot.rs:1231-1244
deviation Poisoned locks recover (PoisonError::into_inner) instead of cascading — Rust's default would have turned one guard panic into a permanently dead e-stop/reset path. Python has no poison concept; the panicking call still dies exactly like the raising call. sync.rs
rust-side wart Controller soft limits are fetched, stored, logged — and never fed into the safety envelope. robot.rs:1011-1014, 1049-1050
Node & wire
rust-side wart NotRunning is dressed as UNKNOWN on the wire so the bridge publishes no reply (matching Python's silence) — semantically it is neither unknown nor an error. grpc.rs:72-76
rust-side wart The robot YAML is parsed up to 4× during bringup (typed, action_port, build_robot, from_yaml_str). main.rs:100-153
rust-side wart missed_deadlines == 0 guard exists only because 0.is_multiple_of(100) is true. telemetry.rs:162
ported bug wrench/wrench_frame are always null in flight-ring state records (FT sensor unported). telemetry.rs:366
rust-side wart set_vacuum with no capability replies false silently (ports getattr(robot, "set_vacuum", None)). node.rs:462
rust-side wart Foreign-robot-id datagrams are dropped silently and uncounted; malformed ones are counted. A mis-configured id looks like a dead link with zero diagnostics. actions.rs:341
rust-side wart Bare --id defaults to "1"; bulk lane drops newest on overflow; action-thread shutdown latency up to 200 ms; reconnect threads joined only at shutdown. main.rs:64 · grpc.rs:166 · actions.rs:263 · node.rs:391
5.3 · Doc ↔ code mismatches found while writing this (worth resolving)
① Phase lock: PORTING.md Part III says "the config flag is removed, the code stays dormant" — but
servoj_phase_lock still exists and is fully wired (config.rs:429, default
false; consumed at robot.rs:729/1296/1315). Dormant only because it
defaults off. Either remove the flag or fix the doc.
② Stale proto comment: proto/robot_node.proto:56-58 still says
orient_primitive "replies null — no DLS solve yet", but the solve shipped 2026-07-22
(kin.rs / orient.rs / robot.rs:2070).
③ Drifting line citations: PORTING.md cites fairino.py:763-764 for the e-stop interface-None quirk; it now lives at fairino.py:794-795.
④ Working tree right now: uncommitted tunes in fairino_fr3_remote_node_local.yaml
(joint_speed 660→60, command_speed 220/660→770/720-760) and spacemouse_leader.yaml
(force_scale ×2.5, k_lin 250→150, max_tcp_v 0.25→0.55) — with the inline comments now stale relative to the
values. Worth committing or reverting deliberately, not by accident.
6 · What the GIL gave Python for free — and Rust spells out
The port's real translation work was not syntax — it was making implicit Python semantics explicit. The flagship example, side by side:
The Rust translation had to preserve three invisible properties
1 · Latest-wins overwrite → Mutex<Option<CommandSlot>>, producer replaces (stream.rs:200).
2 · Read-without-take. Python's consumer "reads the reference once" — it never removes it. A natural Rust Option::take() port would compile, pass casual tests, and silently change starvation accounting + trace record kinds. The slot is cleared only by reset() (stream.rs:207, 245).
3 · Staleness from t_mono, so a re-read inside the window is a fresh re-send, not a hold — pinned by a dedicated test (stream.rs:483).
This pattern — enumerate what the GIL guaranteed, then reproduce each guarantee deliberately — repeats across the port. The table below is the rest of the dictionary.
| Python idiom | Rust construct | The trap it avoids |
|---|---|---|
time.perf_counter() (one process-wide clock) | the one injected SharedClock (safety/clock.rs) | every Instant::now() mints a new epoch — per-thread clocks would land in the same dump offset by arbitrary constants |
if not x: (None or empty) | hand-expanded is_none() || is_empty(), reviewed one by one | if let Some(p) alone silently changes behavior on empty (e.g. the probe's no-measured-pose gate, robot.rs:1896) |
"k" in d gates on the KEY | de_limit_fail_closed: explicit null rejected at deserialize (config.rs:559) | a single Option<T> collapses absent and null — turning Python's startup abort into a silently skipped safety guard |
d.get(k) crashes on a non-dict node | policy_document reproduces the AttributeError (policy.rs:147) | serde's Value::get returns None — a corrupted safety_policy.yaml would load EMPTY and turn every strict robot non-strict |
np.where(mask, …, -inf) + argmax | np_argmax: first max wins, NaN is maximal (guard.rs:41-57) | tie-breaking must match index-for-index, not just value-for-value — metrics name the worst joint |
| exceptions poison nothing | lock_unpoisoned = PoisonError::into_inner (sync.rs) | default poisoning turns one ported ValueError panic into a permanently dead e-stop path |
Tenacity Retrying | three hand-rolled loops with exact stop/wait/exhaustion semantics (robot.rs:988, 1847 · posture.rs:314) | the subtle one: _reach_stream_ready returns false on exhaustion instead of raising |
list[:n] truncates | explicit take/get, never [..n] | sim packets and defensive paths make short arrays real; slicing panics |
| f-string log text | char-for-char identical messages + msgfmt.rs Python-repr rendering | operators, toasts, and grep depend on message text (e.g. near_limit_code) |
try/finally teardown | run-on-both-arms shutdown; flight dump on every exit path incl. SIGTERM/SIGHUP (main.rs:268, 298) | an early-return ? between connect and teardown would skip disconnect and lose the dump |
Same function, one structural difference
Compare with the Rust version in Walkthrough B: logic, ordering, log text, and the interface-down
return True quirk are identical. The one Rust-only subtlety is the latch write: Python's adjacent
if status != X: status = X is GIL-atomic; the Rust version re-checks under the status lock
(newly_latched block) so a concurrently latched e-stop is never overwritten by a stale snapshot —
the "snapshot-then-write status race" from the §9 reviewer watchlist, and the same discipline
read_state_now uses for its ERROR→CONNECTED transition (robot.rs:1345-1368).
If you remember one review heuristic from the port: wherever Python did check-then-write on shared state, look for the Rust re-check under the lock.
7 · What was cut, what was gained
Deliberate cuts (decisions, not omissions)
Drag / hand-guiding — gone entirely. Leaders are SpaceMouse-only. Even the defensive drag-release
calls at the head of every posture walk were cut. Accepted residual risk: an arm left in drag from the vendor
pendant fails the walk's Mode() with err=123, surfacing immediately; recovery is manual at the
pendant. posture.rs:15-22
Force-torque sensor — gone. The follower never reads it; admittance lives leader-side. The CNDE
stream still carries both wrench fields; state.rs ignores them.
Cameras/observations — not in the node. Shared memory dies across hosts; separate workstream.
Legacy ZMQ remote-robot path — deleted post-N-E. The Gate C+ shim work discovered it never carried
prepare_command_stream at all — the old remote path never supported a real streaming arm.
Improvements beyond the 1:1 port (each measured)
Rates matched at 100 Hz — the old 70→50 mismatch manufactured a latency lattice, velocity ripple, and silently discarded ~¼ of setpoints.
Actions moved to UDP (2026-07-20 decision record): latest-wins fits UDP; TCP head-of-line blocking eliminated for the WAN; e-stop dual-pathed onto reliable gRPC.
Cross-host profiler — trace backfeed + in-band clock-offset estimation; the latency panel works without trusting either clock.
Phase-locked send tick — built, validated, currently dormant (owner-disabled 2026-07-21; see mismatch ① in §5.3). Shifts the send grid, never the spacing (±0.3 ms/tick cap).
Parked next: a bounded playout queue (depth ~5, hard age cap, drain-to-newest, e-stops bypass) in front of the mailbox — deliberately post-merge, since it departs from Python parity and needs its own differential scenarios before metal.
8 · How it was proven, and how to read latency numbers
The validation ladder
Golden fixtures per module — every Rust module tested against JSONL vectors generated by executing the Python implementation (floats to 1e-9 relative; control flow exact).
Gate B — connect/disconnect/posture walks/e-stop/reset produce identical SDK call sequences via recorder mocks on both sides.
Gate C — Python driver vs Rust driver against fresh SimMachines on identical recorded input; exact event-sequence equality on the injected-fault scenarios (starve→hold→halt, oversized jump, e-stop mid-stream, ServoJ rejection). 6/6.
N-B/N-C/N-D — node-level command parity, telemetry parity + cadence, then the WAN suite (netem 5/50/150 ms, jitter, 1-5% loss, hard cut): hold/halt behavior, reconnect semantics, zero event loss, measured e-stop latency. WAN 5/5.
N-E — live SpaceMouse sessions, one and two machines, SimMachine then real FR3. The remaining gate
before rust → main is the owner's live checklist (PORTING.md Part III).
⚠ Old-vs-new latency comparisons mislead
If you A/B the old in-process stack against the node and the old one "looks" faster: it measured a shorter path. The old trace chain ends when a setpoint is handed to the sender's mailbox — before the wait for the next send slot and the hardware send, which is the dominant term. The node's numbers include everything up to the hardware send.
Measured honestly (traces joined to the flight-recorder command ring): the node is ~3× faster at the median, far better in the worst case, and an order of magnitude steadier on the send grid — while the old stack also silently discarded a quarter of setpoints to its rate mismatch.
Latency here is dominated by waiting for the next fixed send slot (a controller contract), not by processing — regularity and tail behavior are the meaningful metrics, not medians on a lucky day.
Also: for ~10 min after a mini-PC cold boot, NTP slew makes displayed latency creep ~1 ms/min and trace chains drop as "backwards" — harmless, self-limiting, let the box warm up.
Where to go deeper: rust/PORTING.md is the full contract — §8 sacred quirks, §9 the
reviewer's bug-class watchlist, §13.4 the ported-bug ledger, Part II the node spec, Part III the plain-language
handoff + pre-merge checklist. This document was generated from the code on branch rust
(2026-07-22); line numbers reference that tree.