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/// One source's estimate of the clock, as the selection algorithm sees it.
22#[derive(Clone, Copy, Debug)]
23pub struct Estimate {
24    pub offset_s: f64,
25    pub freq_ppm: Option<f64>,
26    /// Dispersion of the estimate itself — how well this source knows the
27    /// offset, as opposed to how well it knows the path.
28    pub sd_s: f64,
29    pub samples: usize,
30}
31
32/// Several sources, **one clock loop**.
33///
34/// This is the structural fix for multi-source selection, and it is worth
35/// stating why the obvious alternative does not work. The daemon used to give
36/// every source its own [`SyncController`] — its own register *and* its own
37/// frequency, drain and budget — and let only the selected one reach the clock.
38/// That leaves every unselected source having produced a plan that never
39/// happened, and seven different ways of cleaning up after that plan were
40/// measured on the three-server rig. Every one was worse than leaving the
41/// wrong books in place, which is the signature of a wrong model rather than a
42/// wrong patch.
43///
44/// The model was wrong. A frequency correction, a drain and its budget are
45/// properties of THE CLOCK, of which there is one; only the sample history is a
46/// property of a source. So the registers are per-source and everything else is
47/// shared, and an unselected source never produces a plan in the first place —
48/// there is nothing to revert, confirm or adopt, because nothing was ever
49/// booked. The entire class of bug is gone rather than patched.
50///
51/// With one source this is arithmetically identical to what shipped before it,
52/// which is checked by running the corpus against the previous binary.
53pub struct MultiController {
54    /// Per source: the measurement history. Nothing here steers anything.
55    registers: Vec<SampleRegister>,
56    /// Shared: the one loop that decides what the clock is told.
57    discipline: Discipline,
58    /// The *permanent* frequency correction currently commanded.
59    freq_cmd_ppm: f64,
60    /// The *temporary* offset-drain rate currently running.
61    ///
62    /// Tracked apart from the frequency because the two transform stored
63    /// samples differently: a frequency change is permanent and tilts the
64    /// whole history, while a drain consumes offset and must be subtracted as
65    /// one. Folding the drain into the frequency term lets the regression
66    /// slope absorb it, and the loop settles into a constant-offset limit
67    /// cycle — TIMECORP S1 sat pinned at 2 ms until these were separated.
68    drain_ppm: f64,
69    /// Seconds of correction the running drain still owes.
70    ///
71    /// This is the field that makes a drain a **budget** rather than a
72    /// frequency. `ClockCommand::Slew` has always carried `drain_offset`, and
73    /// no driver honoured it: every platform folded the drain into a constant
74    /// frequency that ran until the next plan. So a correction could only ever
75    /// be sized to the poll interval — a faster rate would not stop when the
76    /// offset was gone, it would sail past it. That is why a 500 ms cold start
77    /// spent 16 s on its last 9.8 ms: the remainder was handed the poll's
78    /// deadline instead of its own.
79    ///
80    /// With the budget tracked, a drain ends when it is spent. The rate can
81    /// then be chosen for how fast it is safe to move the clock, which is what
82    /// chrony does, and what its `drain_offset` field promised all along.
83    drain_remaining_s: f64,
84    /// Monotonic time of the last plan, for working out how much drain ran.
85    last_plan_mono_s: Option<f64>,
86    /// Monotonic time up to which the running drain has already been booked
87    /// against the registers.
88    ///
89    /// Separate from `last_plan_mono_s` because with several sources a poll no
90    /// longer implies a plan: an unselected source's exchange advances the
91    /// measurement history without steering anything, and the drain that ran in
92    /// the meantime still has to be booked exactly once. With one source the
93    /// two move together and the arithmetic is unchanged.
94    drain_booked_until: Option<f64>,
95    /// What the last plan changed, so it can be undone if the driver refused it.
96    unapplied: Option<Unapplied>,
97}
98
99/// The bookkeeping one plan performed, kept only until the caller says whether
100/// the clock actually accepted it.
101#[derive(Clone, Copy, Debug)]
102struct Unapplied {
103    mono_s: f64,
104    dfreq_ppm: f64,
105    step_s: f64,
106    freq_cmd_before: f64,
107    drain_ppm_before: f64,
108    drain_remaining_before: f64,
109}
110
111/// What the controller decided, plus what the caller must do about it.
112pub struct ControllerStep {
113    pub plan: Plan,
114    /// Total frequency the driver should command: the permanent correction
115    /// plus the drain currently running.
116    pub applied_ppm: f64,
117    /// The estimate that produced the plan, for reporting.
118    pub estimate_offset_s: f64,
119    pub estimate_freq_ppm: Option<f64>,
120    /// Dispersion of the estimate itself, seconds.
121    ///
122    /// Selection compares intervals of `offset ± root_distance`, and a root
123    /// distance built only from path delay describes how well the NETWORK is
124    /// known while saying nothing about how well this source's own offset is
125    /// known. During acquisition those differ by orders of magnitude: the path
126    /// is a hundred microseconds and the estimate is milliseconds.
127    ///
128    /// Omitting it makes every interval far too narrow to overlap, so a set of
129    /// perfectly healthy servers forms no majority and selection returns
130    /// nothing at all — measured on the three-server rig, 74 polls out of 89.
131    pub estimate_sd_s: f64,
132    pub samples_used: usize,
133    /// What the maximum-change guard made of this correction. Mirrored out of
134    /// the plan so a caller can act on it without matching on the command.
135    pub verdict: ChangeVerdict,
136}
137
138fn new_register(config: &DisciplineConfig) -> SampleRegister {
139    let mut r = SampleRegister::new(REGISTER_CAPACITY);
140    r.set_weight_floor_ratio(config.weight_floor_ratio);
141    r.set_offset_weight_floor_ratio(config.offset_weight_floor_ratio);
142    r.set_offset_age_halflife_s(config.offset_age_halflife_s);
143    r.set_offset_weight_dispersion_k(config.offset_weight_dispersion_k);
144    r.set_slope_density_weighting(config.slope_density_weighting);
145    r.set_adaptive_window(config.adaptive_window);
146    r
147}
148
149impl MultiController {
150    pub fn new(config: DisciplineConfig, sources: usize) -> Self {
151        MultiController {
152            registers: (0..sources.max(1)).map(|_| new_register(&config)).collect(),
153            discipline: Discipline::new(config),
154            freq_cmd_ppm: 0.0,
155            drain_ppm: 0.0,
156            drain_remaining_s: 0.0,
157            last_plan_mono_s: None,
158            drain_booked_until: None,
159            unapplied: None,
160        }
161    }
162
163    /// Undo the bookkeeping of the last plan, because the driver refused it.
164    ///
165    /// **The loop's arithmetic has to describe what the clock actually did.**
166    /// A plan books its own effects the moment it is produced: the frequency
167    /// change tilts every stored sample, a step shifts them, and the drain
168    /// budget starts counting down. The caller then hands the command to the
169    /// platform — which can refuse it. `clock_adjtime` returns `EPERM` the
170    /// moment `CAP_SYS_TIME` goes away, and a seccomp policy or a container
171    /// with a read-only clock refuses it too.
172    ///
173    /// Without this, a refusal is silent and cumulative. The register carries
174    /// corrections that never happened, the regression reads that history as
175    /// truth, and the daemon reports itself synchronised while the clock free
176    /// runs — the worst failure a time daemon has, because nothing looks wrong.
177    ///
178    /// Returns whether there was a plan to revert.
179    pub fn revert_last_plan(&mut self) -> bool {
180        let Some(u) = self.unapplied.take() else {
181            return false;
182        };
183        // Exact inverse of what the plan applied. Every register was tilted by
184        // it — the correction was to the shared clock they all measure — so
185        // every register is put back.
186        for r in &mut self.registers {
187            r.slew_samples(u.mono_s, -u.dfreq_ppm, -u.step_s);
188        }
189        self.freq_cmd_ppm = u.freq_cmd_before;
190        self.drain_ppm = u.drain_ppm_before;
191        self.drain_remaining_s = u.drain_remaining_before;
192        true
193    }
194
195    /// Confirm the last plan reached the clock, so it can no longer be undone.
196    pub fn confirm_last_plan(&mut self) {
197        self.unapplied = None;
198    }
199
200    pub fn freq_ppm(&self) -> f64 {
201        self.freq_cmd_ppm
202    }
203
204    pub fn drain_ppm(&self) -> f64 {
205        self.drain_ppm
206    }
207
208    pub fn applied_ppm(&self) -> f64 {
209        self.freq_cmd_ppm + self.drain_ppm
210    }
211
212    pub fn poll_log2(&self) -> i8 {
213        self.discipline.poll_log2()
214    }
215
216    /// The loop's current poll interval, in seconds.
217    ///
218    /// A source that is not steering still has to be scheduled, and the poll
219    /// interval belongs to the loop rather than to any one source.
220    pub fn poll_interval_s(&self) -> f64 {
221        crate::discipline::exp2i(self.discipline.poll_log2() as i32)
222    }
223
224    pub fn samples(&self) -> usize {
225        self.registers[0].len()
226    }
227
228    pub fn samples_from(&self, index: usize) -> usize {
229        self.registers[index].len()
230    }
231
232    pub fn sources(&self) -> usize {
233        self.registers.len()
234    }
235
236    /// Seed the frequency estimate from persisted drift, so a restart does not
237    /// re-learn what was already known.
238    pub fn preload_frequency(&mut self, freq_ppm: f64) {
239        self.freq_cmd_ppm = freq_ppm;
240    }
241
242    /// The interval to use when an exchange is lost — no plan is produced, but
243    /// the caller still needs to know when to try again.
244    pub fn retry_interval_s(&self) -> f64 {
245        self.discipline.retry_interval_s()
246    }
247
248    /// When the running drain will have spent its budget, if one is running.
249    pub fn drain_completes_at(&self) -> Option<f64> {
250        let last = self.last_plan_mono_s?;
251        if self.drain_ppm == 0.0 || self.drain_remaining_s <= 0.0 {
252            return None;
253        }
254        Some(last + self.drain_remaining_s / (self.drain_ppm.abs() * 1e-6))
255    }
256
257    /// End the drain if its budget is spent, returning the command that leaves
258    /// the clock running at the frequency term alone.
259    ///
260    /// Callers must invoke this as they advance time — the daemon by waking for
261    /// it, the simulator at each substep — or the drain runs on past its budget
262    /// and overshoots, which is the behaviour this exists to end.
263    pub fn poll_drain(&mut self, mono_now_s: f64) -> Option<ClockCommand> {
264        let completes_at = self.drain_completes_at()?;
265        if mono_now_s < completes_at {
266            return None;
267        }
268        let last = self.drain_booked_until?;
269
270        // Book what the clock ACTUALLY received, not the budget.
271        //
272        // The budget says when the drain *should* stop; the driver stops when
273        // it is told to, which is when the caller next looks. A caller that
274        // wakes late has already had the extra correction applied, and booking
275        // only the budget silently loses the difference — the register keeps a
276        // correction the clock really got but the loop never recorded, and the
277        // regression reads it as drift.
278        //
279        // It is not a rounding error. Measured on S6, waking 11 ms after a
280        // 19577 ppm drain expired delivered 215 us that went unbooked, and
281        // that single unbooked correction left a permanent ~180 us offset:
282        // 137 us steady against chrony's 2.5 us, from one late wake-up in a
283        // fifteen-minute run. This is the same failure as a driver silently
284        // clamping a slew — the loop's arithmetic must describe what the clock
285        // did, not what it was asked to do.
286        let consumed = self.drain_ppm * 1e-6 * (mono_now_s - last).max(0.0);
287        if consumed != 0.0 {
288            for r in &mut self.registers {
289                r.slew_samples(mono_now_s, 0.0, consumed);
290            }
291        }
292        self.drain_ppm = 0.0;
293        self.drain_remaining_s = 0.0;
294        self.last_plan_mono_s = Some(mono_now_s);
295        self.drain_booked_until = Some(mono_now_s);
296        Some(ClockCommand::Slew {
297            freq_ppm: self.freq_cmd_ppm,
298            drain_offset: 0.0,
299            drain_rate_ppm: 0.0,
300        })
301    }
302
303    /// Book the drain that has actually run against every register.
304    ///
305    /// Split out of the plan path because with several sources a poll no longer
306    /// implies a plan, and the drain still has to be booked exactly once, on
307    /// every register, whether or not this exchange steers anything.
308    ///
309    /// Deliberately NOT capped by the remaining budget. If the drain ran out it
310    /// was already retired by `poll_drain`, which booked it exactly and zeroed
311    /// the rate, so this reads zero. If it did not run out, it was slewing for
312    /// the whole interval and delivered every bit of it. Capping here books
313    /// less correction than the clock actually received, and the regression
314    /// reads the difference as drift: it settled the frequency estimate about
315    /// 1 ppm off true, which at a 32 s poll is a permanent ~200 us offset. S6
316    /// measured 136 us against chrony's 2.5 us until this cap came out.
317    fn settle_drain(&mut self, mono_now_s: f64) {
318        let drained = match self.drain_booked_until {
319            Some(last) if self.drain_ppm != 0.0 => {
320                self.drain_ppm * 1e-6 * (mono_now_s - last).max(0.0)
321            }
322            _ => 0.0,
323        };
324        if drained != 0.0 {
325            for r in &mut self.registers {
326                r.slew_samples(mono_now_s, 0.0, drained);
327            }
328            self.drain_remaining_s = (self.drain_remaining_s - drained.abs()).max(0.0);
329        }
330        if self.drain_booked_until.is_some() {
331            self.drain_booked_until = Some(mono_now_s);
332        }
333    }
334
335    /// Record one exchange from one source. **Measurement only** — this never
336    /// touches the clock, so calling it for a source that is not selected costs
337    /// nothing and leaves nothing to undo.
338    pub fn observe(&mut self, index: usize, mono_now_s: f64, sample: Sample) -> Estimate {
339        self.settle_drain(mono_now_s);
340        self.registers[index].push(sample);
341        self.estimate(index, mono_now_s)
342    }
343
344    /// This source's current view of the clock.
345    ///
346    /// Regression once it has enough spread; before that the lowest-delay
347    /// single sample, which is the least contaminated reading available.
348    /// Single caller (`observe`), single callee (`regress`), and 37.2 Ir per
349    /// call of its own work: the edge census prices it as a pass-through frame.
350    /// `rusty-compiler-leverage` A3 — a single-caller function that is not
351    /// inlined pays a frame for nothing.
352    #[inline(always)]
353    pub fn estimate(&mut self, index: usize, mono_now_s: f64) -> Estimate {
354        // Bound ONCE. This was three separate `self.registers[index]`
355        // expressions -- three bounds checks and three address computations for
356        // one register (`rusty-curiosity`: bind the thing once and use it
357        // twice, rather than re-deriving the target).
358        let reg = &mut self.registers[index];
359        let samples = reg.len();
360        match reg.regress(mono_now_s) {
361            Some(e) => Estimate {
362                offset_s: e.offset,
363                freq_ppm: e.freq_ppm,
364                sd_s: e.offset_sd.max(1e-7),
365                samples,
366            },
367            None => match reg.best() {
368                Some(best) => Estimate {
369                    offset_s: best.offset,
370                    freq_ppm: None,
371                    sd_s: (best.delay / 2.0).max(1e-7),
372                    samples,
373                },
374                None => Estimate {
375                    offset_s: 0.0,
376                    freq_ppm: None,
377                    sd_s: 1e-3,
378                    samples,
379                },
380            },
381        }
382    }
383
384    /// Steer the clock from one source's estimate.
385    ///
386    /// Call this for the SELECTED source only, passing the [`Estimate`] that
387    /// [`MultiController::observe`] just returned. Its effects are booked
388    /// against every register, because the correction lands on the one clock
389    /// they all measure.
390    ///
391    /// Taking the estimate rather than re-deriving it is not tidiness: the
392    /// regression is the expensive part of a step, and computing it in
393    /// `observe` and again here doubled the cost of the whole discipline —
394    /// measured 13,614 to 26,169 Ir per step.
395    pub fn steer(&mut self, est: Estimate, mono_now_s: f64, leap_pending: bool) -> ControllerStep {
396        let (offset, freq, sd) = (est.offset_s, est.freq_ppm, est.sd_s);
397
398        let plan = self
399            .discipline
400            .on_estimate_with_leap(offset, freq, sd, leap_pending);
401        let freq_cmd_new = self.discipline.freq_ppm();
402        // Captured before the books move, so `revert_last_plan` can put them
403        // back exactly if the clock command is refused.
404        let freq_cmd_before = self.freq_cmd_ppm;
405        let drain_ppm_before = self.drain_ppm;
406        let drain_remaining_before = self.drain_remaining_s;
407
408        // A step moves the clock at once and a frequency change tilts it from
409        // here on; either way the stored history is re-expressed in the new
410        // clock's terms rather than discarded. The new drain is accounted when
411        // it has actually run, by `settle_drain`.
412        let dfreq_ppm = freq_cmd_new - self.freq_cmd_ppm;
413        let step_s = match plan.command {
414            ClockCommand::Step { add_seconds } => add_seconds,
415            ClockCommand::Slew { .. } => 0.0,
416        };
417        match plan.command {
418            ClockCommand::Step { .. } => {
419                self.drain_ppm = 0.0;
420                self.drain_remaining_s = 0.0;
421            }
422            ClockCommand::Slew {
423                drain_offset,
424                drain_rate_ppm,
425                ..
426            } => {
427                self.drain_ppm = drain_rate_ppm.copysign(drain_offset);
428                // The budget: this drain stops once it has moved the clock by
429                // this much, whatever the poll interval says.
430                self.drain_remaining_s = drain_offset.abs();
431            }
432        }
433        // EVERY register, not just the one that produced the plan. This is the
434        // whole point of the shared loop: the correction reaches the clock all
435        // of them are measuring, so all of their histories move with it.
436        for r in &mut self.registers {
437            r.slew_samples(mono_now_s, dfreq_ppm, step_s);
438        }
439
440        self.freq_cmd_ppm = freq_cmd_new;
441        self.last_plan_mono_s = Some(mono_now_s);
442        self.drain_booked_until = Some(mono_now_s);
443        // Remember enough to undo all of the above if the driver refuses it.
444        self.unapplied = Some(Unapplied {
445            mono_s: mono_now_s,
446            dfreq_ppm,
447            step_s,
448            freq_cmd_before,
449            drain_ppm_before,
450            drain_remaining_before,
451        });
452
453        ControllerStep {
454            plan,
455            applied_ppm: self.freq_cmd_ppm + self.drain_ppm,
456            estimate_offset_s: offset,
457            estimate_freq_ppm: freq,
458            samples_used: est.samples,
459            estimate_sd_s: sd,
460            verdict: plan.verdict,
461        }
462    }
463}
464
465/// One source driving one clock — the single-source case, and the type the
466/// simulator and every existing caller use.
467///
468/// A thin facade over [`MultiController`] with exactly one register, so there
469/// is one implementation of the loop rather than two that can drift apart.
470pub struct SyncController {
471    inner: MultiController,
472}
473
474impl SyncController {
475    pub fn new(config: DisciplineConfig) -> Self {
476        SyncController {
477            inner: MultiController::new(config, 1),
478        }
479    }
480
481    /// Feed one completed exchange and get the resulting plan.
482    ///
483    /// `mono_now_s` is the local monotonic clock; `sample.t` should be the
484    /// exchange midpoint on that same timescale.
485    pub fn on_sample(&mut self, mono_now_s: f64, sample: Sample) -> ControllerStep {
486        self.on_sample_with_leap(mono_now_s, sample, false)
487    }
488
489    /// As [`SyncController::on_sample`], told whether the source has announced
490    /// a leap second for the current UTC day.
491    pub fn on_sample_with_leap(
492        &mut self,
493        mono_now_s: f64,
494        sample: Sample,
495        leap_pending: bool,
496    ) -> ControllerStep {
497        let est = self.inner.observe(0, mono_now_s, sample);
498        self.inner.steer(est, mono_now_s, leap_pending)
499    }
500
501    pub fn revert_last_plan(&mut self) -> bool {
502        self.inner.revert_last_plan()
503    }
504    pub fn confirm_last_plan(&mut self) {
505        self.inner.confirm_last_plan()
506    }
507    pub fn freq_ppm(&self) -> f64 {
508        self.inner.freq_ppm()
509    }
510    pub fn drain_ppm(&self) -> f64 {
511        self.inner.drain_ppm()
512    }
513    pub fn applied_ppm(&self) -> f64 {
514        self.inner.applied_ppm()
515    }
516    pub fn poll_log2(&self) -> i8 {
517        self.inner.poll_log2()
518    }
519    pub fn samples(&self) -> usize {
520        self.inner.samples()
521    }
522    pub fn preload_frequency(&mut self, freq_ppm: f64) {
523        self.inner.preload_frequency(freq_ppm)
524    }
525    pub fn retry_interval_s(&self) -> f64 {
526        self.inner.retry_interval_s()
527    }
528    pub fn drain_completes_at(&self) -> Option<f64> {
529        self.inner.drain_completes_at()
530    }
531    pub fn poll_drain(&mut self, mono_now_s: f64) -> Option<ClockCommand> {
532        self.inner.poll_drain(mono_now_s)
533    }
534}
535
536#[cfg(test)]
537mod refusal_tests {
538    use super::*;
539    use crate::filter::Sample;
540
541    fn feed(c: &mut SyncController, n: usize, base: f64) {
542        for i in 0..n {
543            let t = 16.0 * (i as f64 + 1.0);
544            c.on_sample(
545                t,
546                Sample {
547                    t,
548                    offset: base - 20e-6 * t,
549                    delay: 200e-6,
550                    dispersion: 1e-6,
551                },
552            );
553        }
554    }
555
556    /// A refused clock command must leave the controller exactly as it was.
557    ///
558    /// The regression that matters: without this, a daemon that has lost
559    /// CAP_SYS_TIME keeps planning corrections, keeps booking them against its
560    /// own history, and keeps reporting itself synchronised, while the clock it
561    /// believes it is steering runs free.
562    #[test]
563    fn a_refused_command_leaves_no_trace() {
564        let cfg = DisciplineConfig::default();
565        let mut applied = SyncController::new(cfg);
566        let mut refused = SyncController::new(cfg);
567
568        feed(&mut applied, 12, 0.010);
569        feed(&mut refused, 12, 0.010);
570
571        // One more sample on each. The first controller's command reaches the
572        // clock; the second's is refused and reverted.
573        let t = 16.0 * 13.0;
574        let sample = Sample {
575            t,
576            offset: 0.010 - 20e-6 * t,
577            delay: 200e-6,
578            dispersion: 1e-6,
579        };
580        let before_freq = refused.freq_ppm();
581        let before_drain = refused.drain_ppm();
582
583        applied.on_sample(t, sample);
584        applied.confirm_last_plan();
585
586        refused.on_sample(t, sample);
587        assert!(refused.revert_last_plan(), "there was a plan to revert");
588
589        assert_eq!(
590            refused.freq_ppm(),
591            before_freq,
592            "the frequency command survived a refusal"
593        );
594        assert_eq!(
595            refused.drain_ppm(),
596            before_drain,
597            "the drain survived a refusal"
598        );
599
600        // And the stored history must be back where it was: feeding both the
601        // same next sample, the one that reverted must NOT agree with the one
602        // that applied, because their clocks genuinely differ now.
603        let t2 = 16.0 * 14.0;
604        let next = Sample {
605            t: t2,
606            offset: 0.010 - 20e-6 * t2,
607            delay: 200e-6,
608            dispersion: 1e-6,
609        };
610        let a = applied.on_sample(t2, next);
611        let r = refused.on_sample(t2, next);
612        assert_ne!(
613            a.applied_ppm, r.applied_ppm,
614            "a reverted controller behaved identically to one that applied its              command, so the revert did not actually restore the books"
615        );
616    }
617
618    /// Reverting twice, or with nothing outstanding, must be harmless.
619    #[test]
620    fn reverting_nothing_is_a_no_op() {
621        let mut c = SyncController::new(DisciplineConfig::default());
622        assert!(!c.revert_last_plan(), "nothing has been planned yet");
623        feed(&mut c, 6, 0.001);
624        let freq = c.freq_ppm();
625        assert!(c.revert_last_plan());
626        assert!(!c.revert_last_plan(), "a second revert must do nothing");
627        assert_ne!(freq, f64::NAN);
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    /// A toy plant, so the controller is exercised end to end without a
636    /// network: the local clock drifts at `base_freq_ppm` and starts `err` off.
637    fn closed_loop(base_freq_ppm: f64, initial_err_s: f64, polls: usize) -> f64 {
638        let mut controller = SyncController::new(DisciplineConfig {
639            iburst: false,
640            makestep_threshold: None,
641            ..DisciplineConfig::default()
642        });
643        let mut err = initial_err_s;
644        let mut t = 0.0f64;
645
646        for _ in 0..polls {
647            let mono = t + err;
648            // A perfect exchange: the measured offset is exactly -err.
649            let step = controller.on_sample(
650                mono,
651                Sample {
652                    t: mono,
653                    offset: -err,
654                    delay: 0.0002,
655                    dispersion: 0.0,
656                },
657            );
658            let dt = step.plan.next_poll_s;
659            // Integrate the plant: true drift plus whatever we commanded.
660            err += (base_freq_ppm + step.applied_ppm) * 1e-6 * dt;
661            t += dt;
662        }
663        err
664    }
665
666    #[test]
667    fn the_controller_converges_on_a_drifting_clock() {
668        let residual = closed_loop(40.0, 0.030, 80);
669        assert!(
670            residual.abs() < 1e-4,
671            "did not converge: {residual} s remaining"
672        );
673    }
674
675    #[test]
676    fn it_converges_from_either_direction() {
677        for (drift, start) in [
678            (40.0, 0.030),
679            (-40.0, -0.030),
680            (100.0, -0.050),
681            (-15.0, 0.010),
682        ] {
683            let residual = closed_loop(drift, start, 120);
684            assert!(
685                residual.abs() < 1e-3,
686                "drift {drift} ppm from {start} s left {residual} s"
687            );
688        }
689    }
690
691    #[test]
692    fn frequency_and_drain_stay_separate() {
693        // The distinction this type exists to preserve: after a plan, the
694        // permanent frequency and the temporary drain must be individually
695        // recoverable, not merged.
696        let mut controller = SyncController::new(DisciplineConfig {
697            iburst: false,
698            makestep_threshold: None,
699            ..DisciplineConfig::default()
700        });
701        let step = controller.on_sample(
702            0.0,
703            Sample {
704                t: 0.0,
705                offset: 0.001,
706                delay: 0.0002,
707                dispersion: 0.0,
708            },
709        );
710        assert!(
711            controller.drain_ppm() != 0.0,
712            "a non-zero offset should start a drain"
713        );
714        assert_eq!(
715            step.applied_ppm,
716            controller.freq_ppm() + controller.drain_ppm(),
717            "the applied total must be exactly the two parts"
718        );
719    }
720
721    #[test]
722    fn a_failed_exchange_retries_at_the_burst_spacing_not_the_poll_interval() {
723        // A server that has just started answers unsynchronised, and the client
724        // must refuse those. If the refusal costs a full poll interval, a cold
725        // start waits 16 s for its first usable sample — which is exactly what
726        // the first benchmark against chrony caught.
727        let controller = SyncController::new(DisciplineConfig {
728            min_poll: 4, // 16 s
729            iburst: true,
730            ..DisciplineConfig::default()
731        });
732        let retry = controller.retry_interval_s();
733        assert!(
734            retry <= 4.0,
735            "a cold-start retry waited {retry} s; the burst spacing is the point"
736        );
737    }
738
739    #[test]
740    fn once_the_burst_is_spent_retries_use_the_poll_interval() {
741        // The fast retry is for acquisition only — a synchronised client that
742        // loses a packet must not hammer the server.
743        let mut controller = SyncController::new(DisciplineConfig {
744            min_poll: 4,
745            iburst: true,
746            makestep_threshold: None,
747            ..DisciplineConfig::default()
748        });
749        for i in 0..8 {
750            controller.on_sample(
751                i as f64 * 2.0,
752                Sample {
753                    t: i as f64 * 2.0,
754                    offset: 1e-6,
755                    delay: 0.0002,
756                    dispersion: 0.0,
757                },
758            );
759        }
760        assert!(
761            controller.retry_interval_s() >= 16.0,
762            "after the burst, retries must back off to the poll interval"
763        );
764    }
765
766    #[test]
767    fn a_drain_ends_when_its_budget_is_spent() {
768        // `ClockCommand::Slew` has always carried `drain_offset` -- the size of
769        // the correction. Until drains were budgeted nothing honoured it: the
770        // drain was a frequency that ran until the next plan, so its rate could
771        // only ever be "the offset divided by the poll interval".
772        let mut controller = SyncController::new(DisciplineConfig {
773            iburst: false,
774            makestep_threshold: None,
775            ..DisciplineConfig::default()
776        });
777        controller.on_sample(
778            0.0,
779            Sample {
780                t: 0.0,
781                offset: 0.010,
782                delay: 0.0002,
783                dispersion: 0.0,
784            },
785        );
786        let ends = controller
787            .drain_completes_at()
788            .expect("a drain should be running");
789        assert!(ends > 0.0, "drain has no completion time");
790        // Nothing before then...
791        assert!(controller.poll_drain(ends - 1e-6).is_none());
792        assert!(controller.applied_ppm() != 0.0);
793        // ...and it retires exactly once at the end.
794        assert!(controller.poll_drain(ends).is_some());
795        assert!(controller.poll_drain(ends + 1.0).is_none());
796        assert_eq!(
797            controller.drain_ppm(),
798            0.0,
799            "a spent drain must stop slewing the clock"
800        );
801    }
802
803    #[test]
804    fn a_late_retirement_books_what_the_clock_actually_received() {
805        // The budget says when the drain *should* stop; the driver stops when
806        // it is told to. A caller that wakes late has already had the extra
807        // correction applied, and booking only the budget loses the difference
808        // -- the regression then reads it as drift. Measured on S6, one late
809        // wake-up left a permanent ~180 us offset: 136 us steady against
810        // chrony's 2.5 us.
811        let mut controller = SyncController::new(DisciplineConfig {
812            iburst: false,
813            makestep_threshold: None,
814            ..DisciplineConfig::default()
815        });
816        controller.on_sample(
817            0.0,
818            Sample {
819                t: 0.0,
820                offset: 0.010,
821                delay: 0.0002,
822                dispersion: 0.0,
823            },
824        );
825        let rate = controller.drain_ppm();
826        let ends = controller.drain_completes_at().expect("a drain");
827        let late = 0.5;
828
829        // Retire it half a second late, then feed a sample reporting the clock
830        // as correct. If the extra correction were not booked, the loop would
831        // believe an offset it had already removed.
832        controller.poll_drain(ends + late);
833        let step = controller.on_sample(
834            ends + late,
835            Sample {
836                t: ends + late,
837                offset: 0.0,
838                delay: 0.0002,
839                dispersion: 0.0,
840            },
841        );
842        let overrun = rate.abs() * 1e-6 * late;
843        assert!(
844            overrun > 1e-6,
845            "test is vacuous unless the overrun is meaningful"
846        );
847        assert!(
848            step.estimate_offset_s.abs() < 0.010,
849            "late retirement lost correction the clock had already received:              estimate {} s",
850            step.estimate_offset_s
851        );
852    }
853
854    #[test]
855    fn a_preloaded_frequency_is_the_starting_point() {
856        let mut controller = SyncController::new(DisciplineConfig::default());
857        controller.preload_frequency(-12.5);
858        assert!((controller.freq_ppm() + 12.5).abs() < 1e-12);
859    }
860}