franka/realtime.rs
1//! Realtime scheduling helpers — a port of `src/control_tools.cpp` (libfranka 0.21.2).
2//!
3//! A 1 kHz FCI control loop needs `SCHED_FIFO` and a `PREEMPT_RT` kernel to keep its deadline.
4//! libfranka checks both before starting a motion; [`RealtimeConfig`] selects whether this
5//! crate does the same.
6
7/// Whether a control loop insists on realtime scheduling (mirrors `franka::RealtimeConfig`).
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum RealtimeConfig {
10 /// Require a realtime kernel and raise the control thread to the highest `SCHED_FIFO`
11 /// priority; fail with [`crate::error::FrankaError::Realtime`] if either is impossible.
12 /// This is libfranka's default.
13 #[default]
14 Enforce,
15 /// Run anyway on a non-realtime kernel or without the privileges to change the scheduler.
16 ///
17 /// Useful against franka-sim and for exploratory work; the loop will miss cycles under
18 /// load, which the robot reports as a falling `control_command_success_rate` and,
19 /// eventually, a `communication_constraints_violation` reflex.
20 Ignore,
21}
22
23/// Whether the running kernel advertises realtime capabilities.
24///
25/// Port of `franka::hasRealtimeKernel`: read `/sys/kernel/realtime` and parse it as a boolean
26/// the way `std::istream >> bool` does, i.e. `1` is true and everything else is false.
27pub fn has_realtime_kernel() -> bool {
28 match std::fs::read_to_string("/sys/kernel/realtime") {
29 Ok(contents) => contents.trim() == "1",
30 Err(_) => false,
31 }
32}
33
34/// Raises the calling thread to the highest `SCHED_FIFO` priority.
35///
36/// Port of `franka::setCurrentThreadToHighestSchedulerPriority`; the `Err` strings are
37/// libfranka's, verbatim.
38pub fn set_current_thread_to_highest_scheduler_priority() -> Result<(), String> {
39 let thread_priority = unsafe { libc::sched_get_priority_max(libc::SCHED_FIFO) };
40 if thread_priority == -1 {
41 return Err(format!(
42 "libfranka: unable to get maximum possible thread priority: {}",
43 strerror(errno())
44 ));
45 }
46 set_current_thread_scheduler_priority(thread_priority)
47}
48
49/// Puts the calling thread on `SCHED_FIFO` at `priority` (1 to 99 on Linux).
50///
51/// The generalisation of [`set_current_thread_to_highest_scheduler_priority`], which is
52/// this at `sched_get_priority_max(SCHED_FIFO)`. A lower priority is what a program that
53/// runs other realtime threads (a robot-side driver, a second arm) gives a
54/// [`crate::robot::target_control`] loop; the `Err` text is libfranka's.
55pub fn set_current_thread_scheduler_priority(priority: i32) -> Result<(), String> {
56 let thread_priority = priority;
57 // `libc::sched_param` carries extra `SCHED_SPORADIC` fields on musl that glibc's
58 // `sched_param` doesn't have; zero-initializing the rest keeps this portable across libc
59 // flavors (e.g. cross-building for aarch64-unknown-linux-musl). On glibc, where
60 // `sched_priority` is the only field, clippy reads the update as redundant.
61 #[allow(clippy::needless_update)]
62 let param = libc::sched_param {
63 sched_priority: thread_priority,
64 ..unsafe { std::mem::zeroed() }
65 };
66 let rc = unsafe { libc::pthread_setschedparam(libc::pthread_self(), libc::SCHED_FIFO, ¶m) };
67 if rc != 0 {
68 // libfranka formats `std::strerror(errno)` here even though `pthread_setschedparam`
69 // reports its failure through the return value. glibc's implementation does set errno
70 // as well, but we fall back to the return code so the message can never read
71 // "Success".
72 let code = match errno() {
73 0 => rc,
74 e => e,
75 };
76 return Err(format!(
77 "libfranka: unable to set realtime scheduling: {}",
78 strerror(code)
79 ));
80 }
81 Ok(())
82}
83
84/// Pins the calling thread to the single CPU `cpu` with `sched_setaffinity`.
85///
86/// What a [`crate::robot::target_control`] loop does right after raising its priority when
87/// its options name a `cpu`: on a host that keeps a core free for it (`isolcpus`) the loop
88/// then never migrates. `cpu` must be below `CPU_SETSIZE` and present on the machine; the
89/// `Err` names the cpu and the kernel's reason.
90pub fn pin_current_thread_to_cpu(cpu: usize) -> Result<(), String> {
91 let failed = |code: i32| {
92 format!(
93 "franka: unable to pin the thread to cpu {cpu}: {}",
94 strerror(code)
95 )
96 };
97 if cpu >= libc::CPU_SETSIZE as usize {
98 return Err(failed(libc::EINVAL));
99 }
100 // SAFETY: `cpu_set_t` is plain data for which all-zero is a valid (empty) mask, `cpu` is
101 // within its bits (checked above, what `CPU_SET` requires), and the mask outlives the
102 // call; pid 0 is this thread.
103 let rc = unsafe {
104 let mut set: libc::cpu_set_t = std::mem::zeroed();
105 libc::CPU_SET(cpu, &mut set);
106 libc::sched_setaffinity(0, std::mem::size_of::<libc::cpu_set_t>(), &set)
107 };
108 if rc != 0 {
109 return Err(failed(errno()));
110 }
111 Ok(())
112}
113
114/// Nanoseconds on the host's `CLOCK_MONOTONIC`, what `clock_gettime` gives a process in any
115/// language.
116///
117/// Unlike `Instant`, which has no portable epoch, this is comparable between processes on one
118/// host, so two nodes can put their samples on one timeline without exchanging anything. It
119/// counts from boot, does not step with the wall clock, and 0 means the call failed, which on
120/// Linux it does not.
121pub fn monotonic_ns() -> u64 {
122 // Zeroed rather than a field literal: on some targets `timespec` carries private
123 // padding, and a literal naming both fields then does not compile.
124 // SAFETY: all-zero is a valid `timespec`.
125 let mut ts: libc::timespec = unsafe { std::mem::zeroed() };
126 // SAFETY: `ts` is a valid, initialised `timespec` that outlives the call, and
127 // `CLOCK_MONOTONIC` is always available on Linux.
128 if unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) } != 0 {
129 return 0;
130 }
131 ts.tv_sec as u64 * 1_000_000_000 + ts.tv_nsec as u64
132}
133
134/// Error text libfranka raises when `RealtimeConfig::Enforce` meets a non-realtime kernel
135/// (`franka::Robot`'s constructor).
136pub const NO_REALTIME_KERNEL_MESSAGE: &str =
137 "libfranka: Running kernel does not have realtime capabilities.";
138
139/// Checks the realtime prerequisites for `config`, returning libfranka's error text.
140///
141/// With [`RealtimeConfig::Enforce`] this is the check `franka::Robot::Robot` performs before
142/// connecting; with [`RealtimeConfig::Ignore`] it always succeeds.
143pub fn check_realtime(config: RealtimeConfig) -> crate::error::FrankaResult<()> {
144 if config == RealtimeConfig::Enforce && !has_realtime_kernel() {
145 return Err(crate::error::FrankaError::Realtime(
146 NO_REALTIME_KERNEL_MESSAGE.to_string(),
147 ));
148 }
149 Ok(())
150}
151
152fn errno() -> i32 {
153 std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
154}
155
156/// `std::strerror`, so the messages are byte-identical to libfranka's.
157fn strerror(code: i32) -> String {
158 unsafe {
159 std::ffi::CStr::from_ptr(libc::strerror(code))
160 .to_string_lossy()
161 .into_owned()
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn realtime_kernel_detection_matches_sysfs() {
171 let expected = std::fs::read_to_string("/sys/kernel/realtime")
172 .map(|c| c.trim() == "1")
173 .unwrap_or(false);
174 assert_eq!(has_realtime_kernel(), expected);
175 }
176
177 #[test]
178 fn enforce_fails_without_a_realtime_kernel() {
179 assert!(check_realtime(RealtimeConfig::Ignore).is_ok());
180 if has_realtime_kernel() {
181 assert!(check_realtime(RealtimeConfig::Enforce).is_ok());
182 } else {
183 let error = check_realtime(RealtimeConfig::Enforce).unwrap_err();
184 assert_eq!(
185 error.to_string(),
186 "libfranka: Running kernel does not have realtime capabilities."
187 );
188 }
189 }
190
191 #[test]
192 fn highest_scheduler_priority_reports_libfranka_text_on_failure() {
193 // On this machine `RLIMIT_RTPRIO` may or may not allow SCHED_FIFO for an unprivileged
194 // process, so both outcomes are valid; only the failure text is fixed by libfranka.
195 // The call runs on a dedicated thread so that a success cannot leave the test harness
196 // scheduled SCHED_FIFO.
197 let result = std::thread::spawn(set_current_thread_to_highest_scheduler_priority)
198 .join()
199 .unwrap();
200 if let Err(error) = result {
201 assert!(
202 error.starts_with("libfranka: unable to set realtime scheduling: ")
203 || error
204 .starts_with("libfranka: unable to get maximum possible thread priority: "),
205 "unexpected message: {error}"
206 );
207 }
208 }
209
210 /// The CPUs this thread may run on, from `sched_getaffinity`.
211 fn allowed_cpus() -> Vec<usize> {
212 // SAFETY: an all-zero `cpu_set_t` is a valid mask the kernel fills in; every index
213 // passed to `CPU_ISSET` is below `CPU_SETSIZE`.
214 unsafe {
215 let mut set: libc::cpu_set_t = std::mem::zeroed();
216 let size = std::mem::size_of::<libc::cpu_set_t>();
217 assert_eq!(libc::sched_getaffinity(0, size, &mut set), 0);
218 (0..libc::CPU_SETSIZE as usize)
219 .filter(|&cpu| libc::CPU_ISSET(cpu, &set))
220 .collect()
221 }
222 }
223
224 #[test]
225 fn pinning_to_an_allowed_cpu_works_and_an_absent_index_is_refused() {
226 // The lowest allowed cpu rather than 0: a container's cpuset may exclude 0. On a
227 // dedicated thread so the test harness's own affinity is left alone.
228 let allowed = allowed_cpus();
229 let first = allowed[0];
230 let result = std::thread::spawn(move || pin_current_thread_to_cpu(first))
231 .join()
232 .unwrap();
233 assert_eq!(result, Ok(()));
234 let error = pin_current_thread_to_cpu(usize::MAX).unwrap_err();
235 assert!(
236 error.starts_with(&format!(
237 "franka: unable to pin the thread to cpu {}: ",
238 usize::MAX
239 )),
240 "unexpected message: {error}"
241 );
242 // Within CPU_SETSIZE but not allowed here: the kernel refuses. Skipped on a machine
243 // where the highest index is allowed.
244 let last = libc::CPU_SETSIZE as usize - 1;
245 if !allowed.contains(&last) {
246 let absent = std::thread::spawn(move || pin_current_thread_to_cpu(last))
247 .join()
248 .unwrap();
249 assert!(absent.is_err());
250 }
251 }
252
253 #[test]
254 fn the_monotonic_clock_runs_and_counts_from_boot() {
255 let first = monotonic_ns();
256 assert!(first > 0);
257 std::thread::sleep(std::time::Duration::from_millis(2));
258 let second = monotonic_ns();
259 assert!(second > first, "{second} after {first}");
260 // Boot-relative, not process-relative: the value is the host's uptime, which a
261 // process that has just started could not produce from its own clock.
262 let uptime: f64 = std::fs::read_to_string("/proc/uptime")
263 .expect("/proc/uptime")
264 .split_whitespace()
265 .next()
266 .and_then(|s| s.parse().ok())
267 .expect("uptime seconds");
268 let seconds = first as f64 / 1e9;
269 assert!(
270 seconds <= uptime + 5.0, // monotonic stops in suspend, uptime does not
271 "{seconds} s vs uptime {uptime} s"
272 );
273 }
274}