rusty_time_core/client.rs
1//! The client-side synchronisation controller.
2//!
3//! This is the thing that turns exchanges into clock commands: sample register,
4//! discipline loop, and the register bookkeeping that keeps the two consistent
5//! when the clock is adjusted underneath them.
6//!
7//! **It lives here so the daemon and the simulator run the same code.** They
8//! did not, once: the bookkeeping below was written inside the TIMECORP
9//! simulator, and the daemon had no client loop at all. A performance
10//! comparison against another implementation is only worth anything if the
11//! thing measured is the thing that ships, so the logic was moved here and
12//! both call it. The simulator's recorded S1/S6/S8 numbers are the regression
13//! test on that move — they must not change.
14
15use crate::discipline::{ChangeVerdict, ClockCommand, Discipline, DisciplineConfig, Plan};
16use crate::filter::{Sample, SampleRegister};
17
18/// How many samples one source keeps.
19pub const REGISTER_CAPACITY: usize = 64;
20
21/// Drives one source's samples into clock commands.
22pub struct SyncController {
23 register: SampleRegister,
24 discipline: Discipline,
25 /// The *permanent* frequency correction currently commanded.
26 freq_cmd_ppm: f64,
27 /// The *temporary* offset-drain rate currently running.
28 ///
29 /// Tracked apart from the frequency because the two transform stored
30 /// samples differently: a frequency change is permanent and tilts the
31 /// whole history, while a drain consumes offset and must be subtracted as
32 /// one. Folding the drain into the frequency term lets the regression
33 /// slope absorb it, and the loop settles into a constant-offset limit
34 /// cycle — TIMECORP S1 sat pinned at 2 ms until these were separated.
35 drain_ppm: f64,
36 /// Seconds of correction the running drain still owes.
37 ///
38 /// This is the field that makes a drain a **budget** rather than a
39 /// frequency. `ClockCommand::Slew` has always carried `drain_offset`, and
40 /// no driver honoured it: every platform folded the drain into a constant
41 /// frequency that ran until the next plan. So a correction could only ever
42 /// be sized to the poll interval — a faster rate would not stop when the
43 /// offset was gone, it would sail past it. That is why a 500 ms cold start
44 /// spent 16 s on its last 9.8 ms: the remainder was handed the poll's
45 /// deadline instead of its own.
46 ///
47 /// With the budget tracked, a drain ends when it is spent. The rate can
48 /// then be chosen for how fast it is safe to move the clock, which is what
49 /// chrony does, and what its `drain_offset` field promised all along.
50 drain_remaining_s: f64,
51 /// Monotonic time of the last plan, for working out how much drain ran.
52 last_plan_mono_s: Option<f64>,
53 /// What the last plan changed, so it can be undone if the driver refused it.
54 unapplied: Option<Unapplied>,
55}
56
57/// The bookkeeping one plan performed, kept only until the caller says whether
58/// the clock actually accepted it.
59#[derive(Clone, Copy, Debug)]
60struct Unapplied {
61 mono_s: f64,
62 dfreq_ppm: f64,
63 step_s: f64,
64 freq_cmd_before: f64,
65 drain_ppm_before: f64,
66 drain_remaining_before: f64,
67}
68
69/// What the controller decided, plus what the caller must do about it.
70pub struct ControllerStep {
71 pub plan: Plan,
72 /// Total frequency the driver should command: the permanent correction
73 /// plus the drain currently running.
74 pub applied_ppm: f64,
75 /// The estimate that produced the plan, for reporting.
76 pub estimate_offset_s: f64,
77 pub estimate_freq_ppm: Option<f64>,
78 pub samples_used: usize,
79 /// What the maximum-change guard made of this correction. Mirrored out of
80 /// the plan so a caller can act on it without matching on the command.
81 pub verdict: ChangeVerdict,
82}
83
84impl SyncController {
85 pub fn new(config: DisciplineConfig) -> Self {
86 SyncController {
87 register: {
88 let mut r = SampleRegister::new(REGISTER_CAPACITY);
89 r.set_weight_floor_ratio(config.weight_floor_ratio);
90 r.set_offset_weight_floor_ratio(config.offset_weight_floor_ratio);
91 r.set_offset_age_halflife_s(config.offset_age_halflife_s);
92 r.set_offset_weight_dispersion_k(config.offset_weight_dispersion_k);
93 r.set_slope_density_weighting(config.slope_density_weighting);
94 r
95 },
96 discipline: Discipline::new(config),
97 freq_cmd_ppm: 0.0,
98 drain_ppm: 0.0,
99 drain_remaining_s: 0.0,
100 last_plan_mono_s: None,
101 unapplied: None,
102 }
103 }
104
105 /// Undo the bookkeeping of the last plan, because the driver refused it.
106 ///
107 /// **The loop's arithmetic has to describe what the clock actually did.**
108 /// A plan books its own effects the moment it is produced: the frequency
109 /// change tilts every stored sample, a step shifts them, and the drain
110 /// budget starts counting down. The caller then hands the command to the
111 /// platform — which can refuse it. `clock_adjtime` returns `EPERM` the
112 /// moment `CAP_SYS_TIME` goes away, and a seccomp policy or a container
113 /// with a read-only clock refuses it too.
114 ///
115 /// Without this, a refusal is silent and cumulative. The register carries
116 /// corrections that never happened, the regression reads that history as
117 /// truth, and the daemon reports itself synchronised while the clock free
118 /// runs — the worst failure a time daemon has, because nothing looks wrong.
119 ///
120 /// Returns whether there was a plan to revert.
121 pub fn revert_last_plan(&mut self) -> bool {
122 let Some(u) = self.unapplied.take() else {
123 return false;
124 };
125 // Exact inverse of what `on_sample` applied, in the opposite order.
126 self.register
127 .slew_samples(u.mono_s, -u.dfreq_ppm, -u.step_s);
128 self.freq_cmd_ppm = u.freq_cmd_before;
129 self.drain_ppm = u.drain_ppm_before;
130 self.drain_remaining_s = u.drain_remaining_before;
131 true
132 }
133
134 /// Confirm the last plan reached the clock, so it can no longer be undone.
135 pub fn confirm_last_plan(&mut self) {
136 self.unapplied = None;
137 }
138
139 pub fn freq_ppm(&self) -> f64 {
140 self.freq_cmd_ppm
141 }
142
143 pub fn drain_ppm(&self) -> f64 {
144 self.drain_ppm
145 }
146
147 pub fn applied_ppm(&self) -> f64 {
148 self.freq_cmd_ppm + self.drain_ppm
149 }
150
151 pub fn poll_log2(&self) -> i8 {
152 self.discipline.poll_log2()
153 }
154
155 pub fn samples(&self) -> usize {
156 self.register.len()
157 }
158
159 /// Seed the frequency estimate from persisted drift, so a restart does not
160 /// re-learn what was already known.
161 pub fn preload_frequency(&mut self, freq_ppm: f64) {
162 self.freq_cmd_ppm = freq_ppm;
163 }
164
165 /// The interval to use when an exchange is lost — no plan is produced, but
166 /// the caller still needs to know when to try again.
167 pub fn retry_interval_s(&self) -> f64 {
168 self.discipline.retry_interval_s()
169 }
170
171 /// When the running drain will have spent its budget, if one is running.
172 pub fn drain_completes_at(&self) -> Option<f64> {
173 let last = self.last_plan_mono_s?;
174 if self.drain_ppm == 0.0 || self.drain_remaining_s <= 0.0 {
175 return None;
176 }
177 Some(last + self.drain_remaining_s / (self.drain_ppm.abs() * 1e-6))
178 }
179
180 /// End the drain if its budget is spent, returning the command that leaves
181 /// the clock running at the frequency term alone.
182 ///
183 /// Callers must invoke this as they advance time — the daemon by waking for
184 /// it, the simulator at each substep — or the drain runs on past its budget
185 /// and overshoots, which is the behaviour this exists to end.
186 pub fn poll_drain(&mut self, mono_now_s: f64) -> Option<ClockCommand> {
187 let completes_at = self.drain_completes_at()?;
188 if mono_now_s < completes_at {
189 return None;
190 }
191 let last = self.last_plan_mono_s?;
192
193 // Book what the clock ACTUALLY received, not the budget.
194 //
195 // The budget says when the drain *should* stop; the driver stops when
196 // it is told to, which is when the caller next looks. A caller that
197 // wakes late has already had the extra correction applied, and booking
198 // only the budget silently loses the difference — the register keeps a
199 // correction the clock really got but the loop never recorded, and the
200 // regression reads it as drift.
201 //
202 // It is not a rounding error. Measured on S6, waking 11 ms after a
203 // 19577 ppm drain expired delivered 215 us that went unbooked, and
204 // that single unbooked correction left a permanent ~180 us offset:
205 // 137 us steady against chrony's 2.5 us, from one late wake-up in a
206 // fifteen-minute run. This is the same failure as a driver silently
207 // clamping a slew — the loop's arithmetic must describe what the clock
208 // did, not what it was asked to do.
209 let consumed = self.drain_ppm * 1e-6 * (mono_now_s - last).max(0.0);
210 if consumed != 0.0 {
211 self.register.slew_samples(mono_now_s, 0.0, consumed);
212 }
213 self.drain_ppm = 0.0;
214 self.drain_remaining_s = 0.0;
215 self.last_plan_mono_s = Some(mono_now_s);
216 Some(ClockCommand::Slew {
217 freq_ppm: self.freq_cmd_ppm,
218 drain_offset: 0.0,
219 drain_rate_ppm: 0.0,
220 })
221 }
222
223 /// Feed one completed exchange and get the resulting plan.
224 ///
225 /// `mono_now_s` is the local monotonic clock; `sample.t` should be the
226 /// exchange midpoint on that same timescale.
227 pub fn on_sample(&mut self, mono_now_s: f64, sample: Sample) -> ControllerStep {
228 // Account for the drain that actually ran since the last plan. It is a
229 // *consumed offset correction*, so it leaves the stored history as an
230 // offset and the regression slope keeps measuring frequency alone.
231 // What the drain actually delivered since the last plan.
232 //
233 // Deliberately NOT capped by the remaining budget. If the drain ran out
234 // it was already retired by `poll_drain`, which booked it exactly and
235 // zeroed the rate, so this reads zero. If it did not run out, it was
236 // slewing for the whole interval and delivered every bit of it.
237 // Capping here books less correction than the clock actually received,
238 // and the regression reads the difference as drift: it settled the
239 // frequency estimate about 1 ppm off true, which at a 32 s poll is a
240 // permanent ~200 us offset. S6 measured 136 us against chrony's 2.5 us
241 // until this cap came out.
242 let drained = match self.last_plan_mono_s {
243 Some(last) if self.drain_ppm != 0.0 => {
244 self.drain_ppm * 1e-6 * (mono_now_s - last).max(0.0)
245 }
246 _ => 0.0,
247 };
248 if drained != 0.0 {
249 self.register.slew_samples(mono_now_s, 0.0, drained);
250 self.drain_remaining_s = (self.drain_remaining_s - drained.abs()).max(0.0);
251 }
252
253 self.register.push(sample);
254
255 // Regression once it has enough spread; before that the lowest-delay
256 // single sample, which is the least contaminated reading available.
257 let (offset, freq, sd) = match self.register.regress(mono_now_s) {
258 Some(estimate) => (
259 estimate.offset,
260 estimate.freq_ppm,
261 estimate.offset_sd.max(1e-7),
262 ),
263 None => match self.register.best() {
264 Some(best) => (best.offset, None, (best.delay / 2.0).max(1e-7)),
265 None => (0.0, None, 1e-3),
266 },
267 };
268
269 let plan = self.discipline.on_estimate(offset, freq, sd);
270 let freq_cmd_new = self.discipline.freq_ppm();
271 // Captured before the books move, so `revert_last_plan` can put them
272 // back exactly if the clock command is refused.
273 let freq_cmd_before = self.freq_cmd_ppm;
274 let drain_ppm_before = self.drain_ppm;
275 let drain_remaining_before = self.drain_remaining_s;
276
277 match plan.command {
278 ClockCommand::Step { add_seconds } => {
279 // A step moves the clock at once; history is re-expressed in
280 // the new clock's terms rather than discarded.
281 self.register.slew_samples(
282 mono_now_s,
283 freq_cmd_new - self.freq_cmd_ppm,
284 add_seconds,
285 );
286 self.drain_ppm = 0.0;
287 self.drain_remaining_s = 0.0;
288 }
289 ClockCommand::Slew {
290 drain_offset,
291 drain_rate_ppm,
292 ..
293 } => {
294 // The permanent frequency change tilts history now; the new
295 // drain is accounted when it has actually run, at the top of
296 // the next call.
297 self.register
298 .slew_samples(mono_now_s, freq_cmd_new - self.freq_cmd_ppm, 0.0);
299 self.drain_ppm = drain_rate_ppm.copysign(drain_offset);
300 // The budget: this drain stops once it has moved the clock by
301 // this much, whatever the poll interval says.
302 self.drain_remaining_s = drain_offset.abs();
303 }
304 }
305
306 self.freq_cmd_ppm = freq_cmd_new;
307 self.last_plan_mono_s = Some(mono_now_s);
308 // Remember enough to undo all of the above if the driver refuses it.
309 self.unapplied = Some(Unapplied {
310 mono_s: mono_now_s,
311 dfreq_ppm: freq_cmd_new - freq_cmd_before,
312 step_s: match plan.command {
313 ClockCommand::Step { add_seconds } => add_seconds,
314 ClockCommand::Slew { .. } => 0.0,
315 },
316 freq_cmd_before,
317 drain_ppm_before,
318 drain_remaining_before,
319 });
320
321 ControllerStep {
322 plan,
323 applied_ppm: self.freq_cmd_ppm + self.drain_ppm,
324 estimate_offset_s: offset,
325 estimate_freq_ppm: freq,
326 samples_used: self.register.len(),
327 verdict: plan.verdict,
328 }
329 }
330}
331
332#[cfg(test)]
333mod refusal_tests {
334 use super::*;
335 use crate::filter::Sample;
336
337 fn feed(c: &mut SyncController, n: usize, base: f64) {
338 for i in 0..n {
339 let t = 16.0 * (i as f64 + 1.0);
340 c.on_sample(
341 t,
342 Sample {
343 t,
344 offset: base - 20e-6 * t,
345 delay: 200e-6,
346 dispersion: 1e-6,
347 },
348 );
349 }
350 }
351
352 /// A refused clock command must leave the controller exactly as it was.
353 ///
354 /// The regression that matters: without this, a daemon that has lost
355 /// CAP_SYS_TIME keeps planning corrections, keeps booking them against its
356 /// own history, and keeps reporting itself synchronised, while the clock it
357 /// believes it is steering runs free.
358 #[test]
359 fn a_refused_command_leaves_no_trace() {
360 let cfg = DisciplineConfig::default();
361 let mut applied = SyncController::new(cfg);
362 let mut refused = SyncController::new(cfg);
363
364 feed(&mut applied, 12, 0.010);
365 feed(&mut refused, 12, 0.010);
366
367 // One more sample on each. The first controller's command reaches the
368 // clock; the second's is refused and reverted.
369 let t = 16.0 * 13.0;
370 let sample = Sample {
371 t,
372 offset: 0.010 - 20e-6 * t,
373 delay: 200e-6,
374 dispersion: 1e-6,
375 };
376 let before_freq = refused.freq_ppm();
377 let before_drain = refused.drain_ppm();
378
379 applied.on_sample(t, sample);
380 applied.confirm_last_plan();
381
382 refused.on_sample(t, sample);
383 assert!(refused.revert_last_plan(), "there was a plan to revert");
384
385 assert_eq!(
386 refused.freq_ppm(),
387 before_freq,
388 "the frequency command survived a refusal"
389 );
390 assert_eq!(
391 refused.drain_ppm(),
392 before_drain,
393 "the drain survived a refusal"
394 );
395
396 // And the stored history must be back where it was: feeding both the
397 // same next sample, the one that reverted must NOT agree with the one
398 // that applied, because their clocks genuinely differ now.
399 let t2 = 16.0 * 14.0;
400 let next = Sample {
401 t: t2,
402 offset: 0.010 - 20e-6 * t2,
403 delay: 200e-6,
404 dispersion: 1e-6,
405 };
406 let a = applied.on_sample(t2, next);
407 let r = refused.on_sample(t2, next);
408 assert_ne!(
409 a.applied_ppm, r.applied_ppm,
410 "a reverted controller behaved identically to one that applied its command, so the revert did not actually restore the books"
411 );
412 }
413
414 /// Reverting twice, or with nothing outstanding, must be harmless.
415 #[test]
416 fn reverting_nothing_is_a_no_op() {
417 let mut c = SyncController::new(DisciplineConfig::default());
418 assert!(!c.revert_last_plan(), "nothing has been planned yet");
419 feed(&mut c, 6, 0.001);
420 let freq = c.freq_ppm();
421 assert!(c.revert_last_plan());
422 assert!(!c.revert_last_plan(), "a second revert must do nothing");
423 assert_ne!(freq, f64::NAN);
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 /// A toy plant, so the controller is exercised end to end without a
432 /// network: the local clock drifts at `base_freq_ppm` and starts `err` off.
433 fn closed_loop(base_freq_ppm: f64, initial_err_s: f64, polls: usize) -> f64 {
434 let mut controller = SyncController::new(DisciplineConfig {
435 iburst: false,
436 makestep_threshold: None,
437 ..DisciplineConfig::default()
438 });
439 let mut err = initial_err_s;
440 let mut t = 0.0f64;
441
442 for _ in 0..polls {
443 let mono = t + err;
444 // A perfect exchange: the measured offset is exactly -err.
445 let step = controller.on_sample(
446 mono,
447 Sample {
448 t: mono,
449 offset: -err,
450 delay: 0.0002,
451 dispersion: 0.0,
452 },
453 );
454 let dt = step.plan.next_poll_s;
455 // Integrate the plant: true drift plus whatever we commanded.
456 err += (base_freq_ppm + step.applied_ppm) * 1e-6 * dt;
457 t += dt;
458 }
459 err
460 }
461
462 #[test]
463 fn the_controller_converges_on_a_drifting_clock() {
464 let residual = closed_loop(40.0, 0.030, 80);
465 assert!(
466 residual.abs() < 1e-4,
467 "did not converge: {residual} s remaining"
468 );
469 }
470
471 #[test]
472 fn it_converges_from_either_direction() {
473 for (drift, start) in [
474 (40.0, 0.030),
475 (-40.0, -0.030),
476 (100.0, -0.050),
477 (-15.0, 0.010),
478 ] {
479 let residual = closed_loop(drift, start, 120);
480 assert!(
481 residual.abs() < 1e-3,
482 "drift {drift} ppm from {start} s left {residual} s"
483 );
484 }
485 }
486
487 #[test]
488 fn frequency_and_drain_stay_separate() {
489 // The distinction this type exists to preserve: after a plan, the
490 // permanent frequency and the temporary drain must be individually
491 // recoverable, not merged.
492 let mut controller = SyncController::new(DisciplineConfig {
493 iburst: false,
494 makestep_threshold: None,
495 ..DisciplineConfig::default()
496 });
497 let step = controller.on_sample(
498 0.0,
499 Sample {
500 t: 0.0,
501 offset: 0.001,
502 delay: 0.0002,
503 dispersion: 0.0,
504 },
505 );
506 assert!(
507 controller.drain_ppm() != 0.0,
508 "a non-zero offset should start a drain"
509 );
510 assert_eq!(
511 step.applied_ppm,
512 controller.freq_ppm() + controller.drain_ppm(),
513 "the applied total must be exactly the two parts"
514 );
515 }
516
517 #[test]
518 fn a_failed_exchange_retries_at_the_burst_spacing_not_the_poll_interval() {
519 // A server that has just started answers unsynchronised, and the client
520 // must refuse those. If the refusal costs a full poll interval, a cold
521 // start waits 16 s for its first usable sample — which is exactly what
522 // the first benchmark against chrony caught.
523 let controller = SyncController::new(DisciplineConfig {
524 min_poll: 4, // 16 s
525 iburst: true,
526 ..DisciplineConfig::default()
527 });
528 let retry = controller.retry_interval_s();
529 assert!(
530 retry <= 4.0,
531 "a cold-start retry waited {retry} s; the burst spacing is the point"
532 );
533 }
534
535 #[test]
536 fn once_the_burst_is_spent_retries_use_the_poll_interval() {
537 // The fast retry is for acquisition only — a synchronised client that
538 // loses a packet must not hammer the server.
539 let mut controller = SyncController::new(DisciplineConfig {
540 min_poll: 4,
541 iburst: true,
542 makestep_threshold: None,
543 ..DisciplineConfig::default()
544 });
545 for i in 0..8 {
546 controller.on_sample(
547 i as f64 * 2.0,
548 Sample {
549 t: i as f64 * 2.0,
550 offset: 1e-6,
551 delay: 0.0002,
552 dispersion: 0.0,
553 },
554 );
555 }
556 assert!(
557 controller.retry_interval_s() >= 16.0,
558 "after the burst, retries must back off to the poll interval"
559 );
560 }
561
562 #[test]
563 fn a_drain_ends_when_its_budget_is_spent() {
564 // `ClockCommand::Slew` has always carried `drain_offset` -- the size of
565 // the correction. Until drains were budgeted nothing honoured it: the
566 // drain was a frequency that ran until the next plan, so its rate could
567 // only ever be "the offset divided by the poll interval".
568 let mut controller = SyncController::new(DisciplineConfig {
569 iburst: false,
570 makestep_threshold: None,
571 ..DisciplineConfig::default()
572 });
573 controller.on_sample(
574 0.0,
575 Sample {
576 t: 0.0,
577 offset: 0.010,
578 delay: 0.0002,
579 dispersion: 0.0,
580 },
581 );
582 let ends = controller
583 .drain_completes_at()
584 .expect("a drain should be running");
585 assert!(ends > 0.0, "drain has no completion time");
586 // Nothing before then...
587 assert!(controller.poll_drain(ends - 1e-6).is_none());
588 assert!(controller.applied_ppm() != 0.0);
589 // ...and it retires exactly once at the end.
590 assert!(controller.poll_drain(ends).is_some());
591 assert!(controller.poll_drain(ends + 1.0).is_none());
592 assert_eq!(
593 controller.drain_ppm(),
594 0.0,
595 "a spent drain must stop slewing the clock"
596 );
597 }
598
599 #[test]
600 fn a_late_retirement_books_what_the_clock_actually_received() {
601 // The budget says when the drain *should* stop; the driver stops when
602 // it is told to. A caller that wakes late has already had the extra
603 // correction applied, and booking only the budget loses the difference
604 // -- the regression then reads it as drift. Measured on S6, one late
605 // wake-up left a permanent ~180 us offset: 136 us steady against
606 // chrony's 2.5 us.
607 let mut controller = SyncController::new(DisciplineConfig {
608 iburst: false,
609 makestep_threshold: None,
610 ..DisciplineConfig::default()
611 });
612 controller.on_sample(
613 0.0,
614 Sample {
615 t: 0.0,
616 offset: 0.010,
617 delay: 0.0002,
618 dispersion: 0.0,
619 },
620 );
621 let rate = controller.drain_ppm();
622 let ends = controller.drain_completes_at().expect("a drain");
623 let late = 0.5;
624
625 // Retire it half a second late, then feed a sample reporting the clock
626 // as correct. If the extra correction were not booked, the loop would
627 // believe an offset it had already removed.
628 controller.poll_drain(ends + late);
629 let step = controller.on_sample(
630 ends + late,
631 Sample {
632 t: ends + late,
633 offset: 0.0,
634 delay: 0.0002,
635 dispersion: 0.0,
636 },
637 );
638 let overrun = rate.abs() * 1e-6 * late;
639 assert!(
640 overrun > 1e-6,
641 "test is vacuous unless the overrun is meaningful"
642 );
643 assert!(
644 step.estimate_offset_s.abs() < 0.010,
645 "late retirement lost correction the clock had already received: estimate {} s",
646 step.estimate_offset_s
647 );
648 }
649
650 #[test]
651 fn a_preloaded_frequency_is_the_starting_point() {
652 let mut controller = SyncController::new(DisciplineConfig::default());
653 controller.preload_frequency(-12.5);
654 assert!((controller.freq_ppm() + 12.5).abs() < 1e-12);
655 }
656}