epics_libcom_rs/runtime/time.rs
1use std::time::{Duration, Instant, SystemTime};
2
3use crate::walltime::WallTime;
4
5/// Current wall-clock time as a [`WallTime`].
6///
7/// Returns [`WallTime`] rather than [`SystemTime`] so a snapshot built from
8/// "now" shares one timestamp type with snapshots built from exact wire
9/// integers. The OS clock is still read via [`SystemTime::now`]; on Windows
10/// that clock is itself 100 ns-granular, which `WallTime` does not change.
11pub fn now_wall() -> WallTime {
12 SystemTime::now().into()
13}
14
15pub fn now_mono() -> Instant {
16 Instant::now()
17}
18
19/// The instant `base + d`, saturating where `Instant + Duration` would
20/// panic — the single owner of "turn a delay into a deadline".
21///
22/// [`duration_from_secs`] deliberately maps `+inf`, `NaN` and any
23/// magnitude past `Duration`'s range to [`Duration::MAX`], so a deadline
24/// computed from a record delay field can be exactly that sum and
25/// `Instant`'s `Add` panics on it. tokio's `sleep()` does not: it takes
26/// `Instant::now().checked_add(dur)` and falls back to
27/// `Instant::far_future()`, about thirty years out. The hosted build
28/// therefore slept forever where the exec build unwound the task, and it
29/// is that disagreement — not the arithmetic — that is the defect, so
30/// the fallback here is tokio's, to the same constant.
31///
32/// Measured on `armv7-rtems-eabihf` (QEMU `xilinx-zynq-a9`,
33/// `realtime-ca-ioc`), which is where the panic was filed: `caput
34/// RTEMS:BO.HIGH 1e300` then `caput RTEMS:BO 1` makes `bo::process` arm
35/// `DelayedCallbackAfter(Duration::MAX)`. With this fallback the guest
36/// takes it silently and the one-shot simply never fires — the record
37/// still reads `On` eight seconds later, and a 0.5 s one-shot armed
38/// afterwards still reverts it. Rebuilding the same image with `base + d`
39/// in place of the `checked_add` gives *"panic on thread `cbMedium` at
40/// runtime/time.rs: overflow when adding duration to instant"*, so the
41/// embedded timer path really does consume this sum and the guard is what
42/// keeps it off the console. VxWorks is still arithmetic-only.
43pub fn deadline_after(base: Instant, d: Duration) -> Instant {
44 base.checked_add(d).unwrap_or_else(far_future)
45}
46
47/// `base` = now. See [`deadline_after`].
48pub fn deadline_from_now(d: Duration) -> Instant {
49 deadline_after(Instant::now(), d)
50}
51
52/// Roughly thirty years — the offset in tokio's `Instant::far_future()`,
53/// the "never fires" deadline an unrepresentable one collapses to.
54/// Shared with [`crate::runtime::task::deadline_after`], which applies
55/// the same rule to the runtime's own `Instant` alias.
56pub(crate) const FAR_FUTURE: Duration = Duration::from_secs(86_400 * 365 * 30);
57
58/// See [`FAR_FUTURE`].
59fn far_future() -> Instant {
60 Instant::now() + FAR_FUTURE
61}
62
63/// The OS clock-tick period, in seconds — C `epicsThreadSleepQuantum()`.
64///
65/// posix (`libcom/src/osi/os/posix/osdThread.c:1108-1116`):
66///
67/// ```c
68/// double epicsThreadSleepQuantum(void)
69/// {
70/// double hz = sysconf(_SC_CLK_TCK);
71/// if (hz <= 0) return 0.0;
72/// return 1.0 / hz;
73/// }
74/// ```
75///
76/// Records use it to round a delay field to a whole number of ticks — e.g.
77/// `sseqRecord.c:198-200` quantizes every `DLYn` at init. Returns 0.0 when the
78/// tick rate is unavailable, exactly as C does; callers must treat that as "no
79/// quantization" rather than dividing by it.
80///
81/// # Why this asks instead of stating
82///
83/// The tick rate is not ours to declare. On RTEMS it is set by
84/// `CONFIGURE_MICROSECONDS_PER_TICK` in `epics-rtems-boot`'s
85/// `csrc/rtems_config.c`, which is deliberately `#ifndef`-overridable from the
86/// build so a timing experiment needs no source edit. A Rust constant here
87/// would be a second copy of that number, silently wrong the first time
88/// anyone overrides it — with nothing checking the two agree.
89///
90/// So the unix arm asks, and the answer comes from the same define:
91///
92/// ```text
93/// sysconf(_SC_CLK_TCK)
94/// -> rtems_clock_get_ticks_per_second() cpukit/posix/src/sysconf.c:60-61 (rtems_6)
95/// -> _Watchdog_Ticks_per_second rtems/rtems/clock.h:871 (both rtems pins)
96/// = 1000000 / CONFIGURE_MICROSECONDS_PER_TICK confdefs/clock.h:100-101 (rtems_6)
97/// ```
98///
99/// This is also what C itself does on RTEMS — `RTEMS-score/osdThread.c:860-865`
100/// returns `1.0 / rtemsTicksPerSecond_double` rather than a constant — so the
101/// port matches C's behaviour on the target, not just on posix.
102///
103/// The non-unix (Windows) arm keeps a constant. `_SC_CLK_TCK` is 100 on Linux
104/// and macOS, and 100 Hz is the historical default this port shipped; it
105/// restates no `#define` of ours. It is *not* C parity: `WIN32/osdThread.c:906-932`
106/// asks `GetSystemTimeAdjustment` and returns 0.0 on failure. Closing that gap
107/// needs a Windows syscall dependency this crate does not have, and is a
108/// separate change from the RTEMS one.
109pub fn thread_sleep_quantum() -> f64 {
110 #[cfg(unix)]
111 {
112 // SAFETY: `sysconf` is a pure query with no preconditions.
113 let hz = unsafe { libc::sysconf(libc::_SC_CLK_TCK) } as f64;
114 if hz <= 0.0 { 0.0 } else { 1.0 / hz }
115 }
116 #[cfg(not(unix))]
117 {
118 0.01
119 }
120}
121
122/// Round `seconds` to the nearest whole [`thread_sleep_quantum`] tick, the way
123/// C records do it:
124///
125/// ```c
126/// #define NINT(f) (long)((f)>0 ? (f)+0.5 : (f)-0.5)
127/// plinkGroup->dly = epicsThreadSleepQuantum() *
128/// NINT(plinkGroup->dly / epicsThreadSleepQuantum());
129/// ```
130///
131/// (`sseqRecord.c:67`, `:198-199`.) The `NINT` cast is to a C `long` (i64),
132/// NOT an f64 round, and the served DLY must reproduce that cast byte-for-byte
133/// — see `c_long_cast`. Two boundaries C's cast owns that an `f64::trunc`
134/// port gets wrong:
135///
136/// * **Overflow.** A `dly` large enough that `ticks` rounds past 2^63
137/// overflows the `(long)` cast. On x86-64 (the target the oracle runs on)
138/// `cvttsd2si` maps every out-of-range value — and NaN/±inf — to
139/// i64::MIN = `0x8000_0000_0000_0000`, so the field becomes `quantum *
140/// i64::MIN` ≈ -9.22e16, exactly what C serves for a huge `caput`. An
141/// `f64::trunc` port instead keeps the huge value (or `inf`).
142/// * **Negative zero.** A `dly` that rounds to zero yields the *integer* 0,
143/// and `quantum * 0` is `+0.0`. An `f64::trunc` port produces `-0.0` for
144/// the `0.0` default (`(-0.0 - 0.5).trunc()` = `-0.0`) and for any tiny or
145/// negative input that rounds to zero, which renders as `-0` where C
146/// renders `0`.
147///
148/// With a zero quantum (C's `hz <= 0` path) the value is returned unchanged
149/// rather than dividing by zero.
150pub fn quantize_to_sleep_quantum(seconds: f64) -> f64 {
151 let quantum = thread_sleep_quantum();
152 if quantum <= 0.0 {
153 return seconds;
154 }
155 let ticks = seconds / quantum;
156 // C `NINT(f) = (long)((f) > 0 ? (f) + 0.5 : (f) - 0.5)`.
157 let rounded = if ticks > 0.0 {
158 ticks + 0.5
159 } else {
160 ticks - 0.5
161 };
162 quantum * c_long_cast(rounded) as f64
163}
164
165/// Reproduce C's `(long)` cast of a `double` with x86-64 `cvttsd2si`
166/// semantics: an in-range finite value truncates toward zero (as Rust's
167/// `as i64` already does); every out-of-range value and NaN/±inf yields
168/// i64::MIN, the "integer indefinite" the instruction returns.
169///
170/// Rust's own `as i64` *saturates* out-of-range inputs instead (2^63 →
171/// i64::MAX, -inf → i64::MIN, NaN → 0), so the explicit range check is what
172/// makes the port match C on the overflow boundary.
173fn c_long_cast(f: f64) -> i64 {
174 // i64::MIN is exactly -2^63 and representable as f64; i64::MAX rounds up to
175 // 2^63 as f64 (out of range), so the upper bound is a strict `< 2^63`.
176 const MIN: f64 = -9_223_372_036_854_775_808.0; // -2^63
177 const LIMIT: f64 = 9_223_372_036_854_775_808.0; // 2^63
178 if f.is_nan() || f < MIN || f >= LIMIT {
179 i64::MIN
180 } else {
181 f as i64
182 }
183}
184
185/// Seconds as an `f64` → [`Duration`], without the panic
186/// `Duration::from_secs_f64` raises.
187///
188/// This is the libcom time seam's single converter, and every caller
189/// that turns a *record field*, an *environment variable* or any other
190/// externally supplied `double` into a delay must come through it.
191/// `Duration::from_secs_f64` panics on NaN, on either infinity, on a
192/// negative, and on a finite value past `u64::MAX` seconds — and an
193/// `is_finite()` test at the call site is not the rule, because `1e300`
194/// is finite and still panics. `Duration::try_from_secs_f64` is the one
195/// rule that covers all four in a single test.
196///
197/// C never aborts on any of them: `epicsTimeAddSeconds`
198/// (`epicsTime.cpp`) does `nsec += epicsInt64(seconds*1e9 + ...)`, an
199/// out-of-range float→integer conversion, so the deadline is garbage and
200/// the callback fires at the wrong time while the IOC keeps serving
201/// every other PV. The mapping here keeps that "IOC survives" property
202/// and gives the garbage a defined shape:
203///
204/// * negative, including `-inf` → [`Duration::ZERO`] — C's
205/// already-expired deadline, which fires at once.
206/// * `+inf`, `NaN`, or a magnitude beyond `Duration` → [`Duration::MAX`]
207/// — a deadline no comparison ever reaches, i.e. it never fires.
208/// NaN lands here because in C every `now < expire` test against NaN
209/// is false, which is the same "never fires".
210pub fn duration_from_secs(secs: f64) -> Duration {
211 Duration::try_from_secs_f64(secs).unwrap_or(if secs < 0.0 {
212 Duration::ZERO
213 } else {
214 Duration::MAX
215 })
216}
217
218/// Block the calling thread for `secs` — C `epicsThreadSleep`, and the
219/// single owner of "turn a caller-supplied delay into a sleep".
220///
221/// C (`libcom/src/osi/os/posix/osdThread.c:916-934` @R7.0.10) truncates
222/// `seconds` into `timespec.tv_sec`, zeroes the delay when
223/// `seconds <= 0`, and lets `nanosleep` reject whatever will not fit.
224/// Measured against `bin/linux-x86_64/softIoc` driving
225/// `epicsThreadSleep`, `1e300`, `inf`, `nan` and `-5` each return inside
226/// the process's own 0.33 s startup while `0.25` and `1.5` sleep their
227/// full delay: a delay that is not a representable positive `Duration`
228/// is not slept at all.
229///
230/// Deliberately NOT [`duration_from_secs`]. That owner saturates to
231/// [`Duration::MAX`] because a *deadline* built from an absurd delay
232/// must never fire; sleeping on [`Duration::MAX`] would park the caller
233/// for 584 billion years exactly where C returns at once, and
234/// `Duration::from_secs_f64` would panic. The two meanings need two
235/// owners, not one conversion reused on both sides.
236pub fn sleep_secs(secs: f64) {
237 if let Ok(d) = Duration::try_from_secs_f64(secs) {
238 std::thread::sleep(d);
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use source_guard::{Comments, production};
246
247 /// The sleep owner's boundaries are the *opposite* of the deadline
248 /// owner's, which is the whole reason it exists: everything
249 /// `duration_from_secs` saturates to [`Duration::MAX`] is a delay
250 /// `nanosleep` refuses, so C returns from it at once and so must we.
251 ///
252 /// The pairs measured against `softIoc`: `1e300`, `inf`, `nan`, `-5`
253 /// returned in C's 0.33 s startup baseline; `0.25` and `1.5` slept.
254 #[test]
255 fn sleep_secs_returns_at_once_on_every_delay_nanosleep_refuses() {
256 for refused in [
257 f64::INFINITY,
258 f64::NEG_INFINITY,
259 f64::NAN,
260 1e300,
261 -5.0,
262 u64::MAX as f64,
263 ] {
264 let t = Instant::now();
265 sleep_secs(refused);
266 assert!(
267 t.elapsed() < Duration::from_millis(50),
268 "sleep_secs({refused}) must return at once, as C's nanosleep does"
269 );
270 }
271 // Zero is representable, and C's `nanosleep(0,0)` also returns
272 // at once — it must not be confused with the refused set.
273 let t = Instant::now();
274 sleep_secs(0.0);
275 assert!(t.elapsed() < Duration::from_millis(50));
276 // A representable positive delay is slept in full.
277 let t = Instant::now();
278 sleep_secs(0.05);
279 assert!(t.elapsed() >= Duration::from_millis(50));
280 }
281
282 /// Boundaries of the one rule, not scenarios: every input
283 /// `Duration::from_secs_f64` would panic on has a defined answer
284 /// here, and the representable ones convert unchanged.
285 #[test]
286 fn duration_from_secs_covers_every_panic_boundary() {
287 assert_eq!(duration_from_secs(f64::INFINITY), Duration::MAX);
288 assert_eq!(duration_from_secs(f64::NEG_INFINITY), Duration::ZERO);
289 assert_eq!(duration_from_secs(f64::NAN), Duration::MAX);
290 // Finite and far too large — the case an `is_finite()` guard
291 // lets through and `from_secs_f64` still panics on.
292 assert_eq!(duration_from_secs(1e300), Duration::MAX);
293 // The representable edge: `u64::MAX` seconds is out of range,
294 // one below the power of two above it is not.
295 assert_eq!(duration_from_secs(u64::MAX as f64), Duration::MAX);
296 assert_eq!(
297 duration_from_secs(9.0e18),
298 Duration::try_from_secs_f64(9.0e18).unwrap()
299 );
300 assert_eq!(duration_from_secs(-1.0), Duration::ZERO);
301 assert_eq!(duration_from_secs(-0.0), Duration::ZERO);
302 assert_eq!(duration_from_secs(0.0), Duration::ZERO);
303 assert_eq!(duration_from_secs(0.25), Duration::from_millis(250));
304 assert_eq!(duration_from_secs(2.5), Duration::from_millis(2500));
305 }
306
307 /// The tick rate must be ASKED for, never restated.
308 ///
309 /// `CONFIGURE_MICROSECONDS_PER_TICK` in `epics-rtems-boot`'s
310 /// `csrc/rtems_config.c` owns the number, and it is `#ifndef`-overridable
311 /// from the build. A Rust constant restating it is a second source of
312 /// truth that goes silently wrong the first time anyone overrides it.
313 ///
314 /// Fails today, on Linux, with no cross toolchain.
315 #[test]
316 fn the_tick_rate_is_asked_for_not_restated() {
317 // `Strip`: the doc above `thread_sleep_quantum` spells out
318 // `1000000 / CONFIGURE_MICROSECONDS_PER_TICK` to explain the chain,
319 // and that text must not read as a restatement.
320 let src = production(include_str!("time.rs"), Comments::Strip);
321
322 assert!(
323 src.contains("libc::sysconf(libc::_SC_CLK_TCK)"),
324 "thread_sleep_quantum must ask the OS for the tick rate"
325 );
326
327 // The defect this replaced: asking only on Linux, and handing every
328 // other unix — RTEMS included — a constant.
329 assert!(
330 !src.contains("#[cfg(target_os = \"linux\")]"),
331 "the tick-rate arm must select on `unix`, not on `linux`: RTEMS is \
332 a unix that answers _SC_CLK_TCK from the boot crate's define"
333 );
334 assert_eq!(
335 src.matches("#[cfg(unix)]").count(),
336 1,
337 "exactly one arm asks; if a second appears, this guard needs updating"
338 );
339
340 // 10 ms expressed any of the ways someone would naturally write it.
341 // The sole surviving literal is the documented Windows arm.
342 assert_eq!(
343 src.matches("0.01").count(),
344 1,
345 "0.01 may appear only once, in the non-unix arm"
346 );
347 for restatement in ["10000", "10_000", "0.010", "1e-2"] {
348 assert!(
349 !src.contains(restatement),
350 "`{restatement}` restates CONFIGURE_MICROSECONDS_PER_TICK; \
351 read it back through sysconf instead"
352 );
353 }
354 }
355
356 #[test]
357 fn test_now_wall() {
358 let t = now_wall();
359 assert!(t.since_unix_epoch().as_secs() > 0);
360 }
361
362 #[test]
363 fn test_now_mono() {
364 let t1 = now_mono();
365 let t2 = now_mono();
366 assert!(t2 >= t1);
367 }
368
369 /// Boundaries of the deadline owner: a representable delay converts
370 /// unchanged, and the one `Instant + Duration` panics on —
371 /// `Duration::MAX`, which is exactly what `duration_from_secs`
372 /// returns for `+inf`, `NaN` and `1e300` — saturates instead.
373 #[test]
374 fn deadline_saturates_where_instant_add_would_panic() {
375 let base = Instant::now();
376 assert_eq!(
377 deadline_after(base, Duration::from_secs(10)),
378 base + Duration::from_secs(10)
379 );
380 let never = deadline_after(base, Duration::MAX);
381 assert!(never > base + Duration::from_secs(86_400 * 365));
382 assert!(deadline_from_now(duration_from_secs(f64::INFINITY)) > Instant::now());
383 assert!(deadline_from_now(duration_from_secs(1e300)) > Instant::now());
384 // Saturating twice still saturates.
385 assert!(deadline_after(never, Duration::MAX) > base);
386 }
387
388 #[test]
389 fn test_deadline_from_now() {
390 let before = Instant::now();
391 let deadline = deadline_from_now(Duration::from_secs(10));
392 assert!(deadline > before);
393 assert!(deadline <= before + Duration::from_secs(11));
394 }
395
396 /// C's `(long)` cast, boundary by boundary: in-range truncation toward
397 /// zero, and i64::MIN for everything x86-64 `cvttsd2si` cannot represent.
398 #[test]
399 fn c_long_cast_matches_cvttsd2si() {
400 // In range: plain truncation toward zero, both signs.
401 assert_eq!(c_long_cast(0.0), 0);
402 assert_eq!(c_long_cast(-0.0), 0);
403 assert_eq!(c_long_cast(0.5), 0);
404 assert_eq!(c_long_cast(-0.5), 0); // toward zero, NOT -1
405 assert_eq!(c_long_cast(1.9), 1);
406 assert_eq!(c_long_cast(-1.9), -1);
407 // The representable extremes.
408 assert_eq!(c_long_cast(-9_223_372_036_854_775_808.0), i64::MIN);
409 // Out of range and non-finite all collapse to the "integer indefinite".
410 assert_eq!(c_long_cast(9_223_372_036_854_775_808.0), i64::MIN); // +2^63
411 assert_eq!(c_long_cast(1e300), i64::MIN);
412 assert_eq!(c_long_cast(-1e300), i64::MIN);
413 assert_eq!(c_long_cast(f64::INFINITY), i64::MIN);
414 assert_eq!(c_long_cast(f64::NEG_INFINITY), i64::MIN);
415 assert_eq!(c_long_cast(f64::NAN), i64::MIN);
416 }
417
418 /// The DLY quantization the served value must match, by invariant boundary.
419 #[test]
420 fn quantize_dly_boundaries_match_c() {
421 let q = thread_sleep_quantum();
422 assert!(q > 0.0, "test assumes a positive clock quantum, got {q}");
423
424 // The default DLY is 0.0; it must serve as +0.0, never -0.0.
425 let zero = quantize_to_sleep_quantum(0.0);
426 assert_eq!(zero, 0.0);
427 assert!(
428 zero.is_sign_positive(),
429 "DLY=0.0 must round to +0.0 (renders \"0\"), got a negative zero"
430 );
431
432 // A -0.0 input must also normalize to +0.0 (C's `NINT` yields integer 0).
433 let neg_zero = quantize_to_sleep_quantum(-0.0);
434 assert_eq!(neg_zero, 0.0);
435 assert!(
436 neg_zero.is_sign_positive(),
437 "DLY=-0.0 must round to +0.0, got a negative zero"
438 );
439
440 // A tiny positive below half a tick rounds to +0.0.
441 let tiny = quantize_to_sleep_quantum(q / 4.0);
442 assert_eq!(tiny, 0.0);
443 assert!(tiny.is_sign_positive(), "tiny +dly rounds to +0.0");
444
445 // A small negative that rounds to zero: C truncates `-0.x` to 0 → +0.0.
446 let tiny_neg = quantize_to_sleep_quantum(-q / 4.0);
447 assert_eq!(tiny_neg, 0.0);
448 assert!(
449 tiny_neg.is_sign_positive(),
450 "a -dly rounding to zero must serve +0.0, not -0.0"
451 );
452
453 // An exact quantum multiple is preserved exactly.
454 assert_eq!(quantize_to_sleep_quantum(3.0 * q), 3.0 * q);
455 assert_eq!(quantize_to_sleep_quantum(-3.0 * q), -3.0 * q);
456
457 // Round-half-away-from-zero at the tick boundary.
458 assert_eq!(quantize_to_sleep_quantum(1.5 * q), 2.0 * q);
459 assert_eq!(quantize_to_sleep_quantum(-1.5 * q), -2.0 * q);
460
461 // A huge finite `dly` overflows the `(long)` cast to i64::MIN, so the
462 // served value is `quantum * i64::MIN` — negative, and NOT `inf`.
463 let huge = quantize_to_sleep_quantum(1e300);
464 assert_eq!(huge, q * (i64::MIN as f64));
465 assert!(
466 huge.is_finite() && huge < 0.0,
467 "a huge +dly must serve C's ~-9.22e16, not inf, got {huge}"
468 );
469 // A huge negative `dly` overflows the same way.
470 let huge_neg = quantize_to_sleep_quantum(-1e300);
471 assert_eq!(huge_neg, q * (i64::MIN as f64));
472
473 // ±inf also collapses to i64::MIN (an `f64::trunc` port kept inf).
474 assert_eq!(
475 quantize_to_sleep_quantum(f64::INFINITY),
476 q * (i64::MIN as f64)
477 );
478 assert_eq!(
479 quantize_to_sleep_quantum(f64::NEG_INFINITY),
480 q * (i64::MIN as f64)
481 );
482 }
483}