Controllers — Realtime C++ Guidelines
This file contains guidance specific to the src/controllers/ packages — ros2_control controllers whose update() runs on the realtime control loop.
It supplements the project-wide AGENTS.md and .claude/rules/ with rules that apply when code can be reached from a realtime thread. General C++ style, testing, and git rules live in the project-wide files.
Realtime Discipline
Every function reachable from a controller's update() (or any other RT thread) must be allocation-free, lock-free, and bounded in execution time. The malloc_counter test harness (see src/controllers/malloc_counter/) and EXPECT_NO_MALLOC make these claims testable — use them.
RT-1. Helpers must own their no-allocation contract; don't outsource it to callers
A helper that formats into a caller-supplied std::string& from the realtime control loop must (a) clear the buffer itself rather than rely on the caller to remember, and (b) bound the written size so the underlying assign/push_back/format_to cannot exceed the buffer's reserved capacity.
The recommended pattern: format into a fixed-size stack buffer with std::array<char, N> + fmt::format_to_n (which truncates rather than reallocates), then assign into the output once. std::string::assign(const char*, size_t) does not call malloc on libstdc++ 11/14 when the new size is within the existing capacity.
A doc-only contract like *"caller must `reserve(N)` and `clear()` first"* is fragile: one forgetful caller silently triggers malloc() on the RT thread, which the test suite will rarely catch unless EXPECT_NO_MALLOC happens to wrap the exact iteration that trips. Make the helper enforce its own invariants.
See JointTrajectoryAdmittanceController::writeToleranceViolation for the canonical example.
RT-2. Pre-reserve realtime-shared buffers at goal-acceptance, not in update()
Anything the RT loop writes into — action-result error_message strings, log scratch buffers, command queues — must have its capacity pinned before the RT loop touches it. The right place is the goal-acceptance callback (e.g. goal_accepted_callback), which runs on the action server's executor thread, not the RT thread. Reserving there is free; reserving in update() is a bug.
RT-3. Logging from update() must be bounded
spdlog::info/warn/error use a per-message stack buffer (default 250 bytes via SPDLOG_FMT_STRING_BUFFER_SIZE in spdlog's tweakme.h). Messages that exceed it heap-allocate. Prefer logging raw numeric vectors over name-enriched strings when DoF is high. If a long message is unavoidable, route it to the action-result error_message (which is pre-reserved per RT-2) instead of the log.
RT-4. Don't take heap-allocating standard-library operations as noexcept
Per .claude/rules/cpp-style.md, anything that allocates strings or vectors should not be marked noexcept. In RT controller code this matters double, because a noexcept lie hides the very allocation you're trying to avoid.
Test Patterns
- Wrap controller->update(...) calls in EXPECT_NO_MALLOC(...) so allocations show up as test failures rather than silent realtime jitter. Note that this only catches allocations on iterations the macro wraps — to pin coverage on a specific code path (e.g. the tolerance-violation branch), wrap the loop body unconditionally and let it execute through the trip iteration.
- Prefer extracted pure functions for the heavy logic and unit-test those independently of the RT plumbing. The RT integration test then only needs to verify the dispatch.
Reference Files
- src/controllers/malloc_counter/ — EXPECT_NO_MALLOC macro and underlying hook.
- src/controllers/joint_trajectory_admittance_controller/src/joint_trajectory_admittance_controller.cpp — writeToleranceViolation (RT-1, RT-3) and goal-acceptance reservation (RT-2).
Generated via doxygen2docusaurus 2.2.2 by Doxygen 1.9.8.