Skip to main content

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, &param) };
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}