Skip to main content

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}