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        (2.0f64).powi(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    pub fn estimate(&mut self, index: usize, mono_now_s: f64) -> Estimate {
349        let samples = self.registers[index].len();
350        match self.registers[index].regress(mono_now_s) {
351            Some(e) => Estimate {
352                offset_s: e.offset,
353                freq_ppm: e.freq_ppm,
354                sd_s: e.offset_sd.max(1e-7),
355                samples,
356            },
357            None => match self.registers[index].best() {
358                Some(best) => Estimate {
359                    offset_s: best.offset,
360                    freq_ppm: None,
361                    sd_s: (best.delay / 2.0).max(1e-7),
362                    samples,
363                },
364                None => Estimate {
365                    offset_s: 0.0,
366                    freq_ppm: None,
367                    sd_s: 1e-3,
368                    samples,
369                },
370            },
371        }
372    }
373
374    /// Steer the clock from one source's estimate.
375    ///
376    /// Call this for the SELECTED source only, passing the [`Estimate`] that
377    /// [`MultiController::observe`] just returned. Its effects are booked
378    /// against every register, because the correction lands on the one clock
379    /// they all measure.
380    ///
381    /// Taking the estimate rather than re-deriving it is not tidiness: the
382    /// regression is the expensive part of a step, and computing it in
383    /// `observe` and again here doubled the cost of the whole discipline —
384    /// measured 13,614 to 26,169 Ir per step.
385    pub fn steer(&mut self, est: Estimate, mono_now_s: f64, leap_pending: bool) -> ControllerStep {
386        let (offset, freq, sd) = (est.offset_s, est.freq_ppm, est.sd_s);
387
388        let plan = self
389            .discipline
390            .on_estimate_with_leap(offset, freq, sd, leap_pending);
391        let freq_cmd_new = self.discipline.freq_ppm();
392        // Captured before the books move, so `revert_last_plan` can put them
393        // back exactly if the clock command is refused.
394        let freq_cmd_before = self.freq_cmd_ppm;
395        let drain_ppm_before = self.drain_ppm;
396        let drain_remaining_before = self.drain_remaining_s;
397
398        // A step moves the clock at once and a frequency change tilts it from
399        // here on; either way the stored history is re-expressed in the new
400        // clock's terms rather than discarded. The new drain is accounted when
401        // it has actually run, by `settle_drain`.
402        let dfreq_ppm = freq_cmd_new - self.freq_cmd_ppm;
403        let step_s = match plan.command {
404            ClockCommand::Step { add_seconds } => add_seconds,
405            ClockCommand::Slew { .. } => 0.0,
406        };
407        match plan.command {
408            ClockCommand::Step { .. } => {
409                self.drain_ppm = 0.0;
410                self.drain_remaining_s = 0.0;
411            }
412            ClockCommand::Slew {
413                drain_offset,
414                drain_rate_ppm,
415                ..
416            } => {
417                self.drain_ppm = drain_rate_ppm.copysign(drain_offset);
418                // The budget: this drain stops once it has moved the clock by
419                // this much, whatever the poll interval says.
420                self.drain_remaining_s = drain_offset.abs();
421            }
422        }
423        // EVERY register, not just the one that produced the plan. This is the
424        // whole point of the shared loop: the correction reaches the clock all
425        // of them are measuring, so all of their histories move with it.
426        for r in &mut self.registers {
427            r.slew_samples(mono_now_s, dfreq_ppm, step_s);
428        }
429
430        self.freq_cmd_ppm = freq_cmd_new;
431        self.last_plan_mono_s = Some(mono_now_s);
432        self.drain_booked_until = Some(mono_now_s);
433        // Remember enough to undo all of the above if the driver refuses it.
434        self.unapplied = Some(Unapplied {
435            mono_s: mono_now_s,
436            dfreq_ppm,
437            step_s,
438            freq_cmd_before,
439            drain_ppm_before,
440            drain_remaining_before,
441        });
442
443        ControllerStep {
444            plan,
445            applied_ppm: self.freq_cmd_ppm + self.drain_ppm,
446            estimate_offset_s: offset,
447            estimate_freq_ppm: freq,
448            samples_used: est.samples,
449            estimate_sd_s: sd,
450            verdict: plan.verdict,
451        }
452    }
453}
454
455/// One source driving one clock — the single-source case, and the type the
456/// simulator and every existing caller use.
457///
458/// A thin facade over [`MultiController`] with exactly one register, so there
459/// is one implementation of the loop rather than two that can drift apart.
460pub struct SyncController {
461    inner: MultiController,
462}
463
464impl SyncController {
465    pub fn new(config: DisciplineConfig) -> Self {
466        SyncController {
467            inner: MultiController::new(config, 1),
468        }
469    }
470
471    /// Feed one completed exchange and get the resulting plan.
472    ///
473    /// `mono_now_s` is the local monotonic clock; `sample.t` should be the
474    /// exchange midpoint on that same timescale.
475    pub fn on_sample(&mut self, mono_now_s: f64, sample: Sample) -> ControllerStep {
476        self.on_sample_with_leap(mono_now_s, sample, false)
477    }
478
479    /// As [`SyncController::on_sample`], told whether the source has announced
480    /// a leap second for the current UTC day.
481    pub fn on_sample_with_leap(
482        &mut self,
483        mono_now_s: f64,
484        sample: Sample,
485        leap_pending: bool,
486    ) -> ControllerStep {
487        let est = self.inner.observe(0, mono_now_s, sample);
488        self.inner.steer(est, mono_now_s, leap_pending)
489    }
490
491    pub fn revert_last_plan(&mut self) -> bool {
492        self.inner.revert_last_plan()
493    }
494    pub fn confirm_last_plan(&mut self) {
495        self.inner.confirm_last_plan()
496    }
497    pub fn freq_ppm(&self) -> f64 {
498        self.inner.freq_ppm()
499    }
500    pub fn drain_ppm(&self) -> f64 {
501        self.inner.drain_ppm()
502    }
503    pub fn applied_ppm(&self) -> f64 {
504        self.inner.applied_ppm()
505    }
506    pub fn poll_log2(&self) -> i8 {
507        self.inner.poll_log2()
508    }
509    pub fn samples(&self) -> usize {
510        self.inner.samples()
511    }
512    pub fn preload_frequency(&mut self, freq_ppm: f64) {
513        self.inner.preload_frequency(freq_ppm)
514    }
515    pub fn retry_interval_s(&self) -> f64 {
516        self.inner.retry_interval_s()
517    }
518    pub fn drain_completes_at(&self) -> Option<f64> {
519        self.inner.drain_completes_at()
520    }
521    pub fn poll_drain(&mut self, mono_now_s: f64) -> Option<ClockCommand> {
522        self.inner.poll_drain(mono_now_s)
523    }
524}
525
526#[cfg(test)]
527mod refusal_tests {
528    use super::*;
529    use crate::filter::Sample;
530
531    fn feed(c: &mut SyncController, n: usize, base: f64) {
532        for i in 0..n {
533            let t = 16.0 * (i as f64 + 1.0);
534            c.on_sample(
535                t,
536                Sample {
537                    t,
538                    offset: base - 20e-6 * t,
539                    delay: 200e-6,
540                    dispersion: 1e-6,
541                },
542            );
543        }
544    }
545
546    /// A refused clock command must leave the controller exactly as it was.
547    ///
548    /// The regression that matters: without this, a daemon that has lost
549    /// CAP_SYS_TIME keeps planning corrections, keeps booking them against its
550    /// own history, and keeps reporting itself synchronised, while the clock it
551    /// believes it is steering runs free.
552    #[test]
553    fn a_refused_command_leaves_no_trace() {
554        let cfg = DisciplineConfig::default();
555        let mut applied = SyncController::new(cfg);
556        let mut refused = SyncController::new(cfg);
557
558        feed(&mut applied, 12, 0.010);
559        feed(&mut refused, 12, 0.010);
560
561        // One more sample on each. The first controller's command reaches the
562        // clock; the second's is refused and reverted.
563        let t = 16.0 * 13.0;
564        let sample = Sample {
565            t,
566            offset: 0.010 - 20e-6 * t,
567            delay: 200e-6,
568            dispersion: 1e-6,
569        };
570        let before_freq = refused.freq_ppm();
571        let before_drain = refused.drain_ppm();
572
573        applied.on_sample(t, sample);
574        applied.confirm_last_plan();
575
576        refused.on_sample(t, sample);
577        assert!(refused.revert_last_plan(), "there was a plan to revert");
578
579        assert_eq!(
580            refused.freq_ppm(),
581            before_freq,
582            "the frequency command survived a refusal"
583        );
584        assert_eq!(
585            refused.drain_ppm(),
586            before_drain,
587            "the drain survived a refusal"
588        );
589
590        // And the stored history must be back where it was: feeding both the
591        // same next sample, the one that reverted must NOT agree with the one
592        // that applied, because their clocks genuinely differ now.
593        let t2 = 16.0 * 14.0;
594        let next = Sample {
595            t: t2,
596            offset: 0.010 - 20e-6 * t2,
597            delay: 200e-6,
598            dispersion: 1e-6,
599        };
600        let a = applied.on_sample(t2, next);
601        let r = refused.on_sample(t2, next);
602        assert_ne!(
603            a.applied_ppm, r.applied_ppm,
604            "a reverted controller behaved identically to one that applied its              command, so the revert did not actually restore the books"
605        );
606    }
607
608    /// Reverting twice, or with nothing outstanding, must be harmless.
609    #[test]
610    fn reverting_nothing_is_a_no_op() {
611        let mut c = SyncController::new(DisciplineConfig::default());
612        assert!(!c.revert_last_plan(), "nothing has been planned yet");
613        feed(&mut c, 6, 0.001);
614        let freq = c.freq_ppm();
615        assert!(c.revert_last_plan());
616        assert!(!c.revert_last_plan(), "a second revert must do nothing");
617        assert_ne!(freq, f64::NAN);
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    /// A toy plant, so the controller is exercised end to end without a
626    /// network: the local clock drifts at `base_freq_ppm` and starts `err` off.
627    fn closed_loop(base_freq_ppm: f64, initial_err_s: f64, polls: usize) -> f64 {
628        let mut controller = SyncController::new(DisciplineConfig {
629            iburst: false,
630            makestep_threshold: None,
631            ..DisciplineConfig::default()
632        });
633        let mut err = initial_err_s;
634        let mut t = 0.0f64;
635
636        for _ in 0..polls {
637            let mono = t + err;
638            // A perfect exchange: the measured offset is exactly -err.
639            let step = controller.on_sample(
640                mono,
641                Sample {
642                    t: mono,
643                    offset: -err,
644                    delay: 0.0002,
645                    dispersion: 0.0,
646                },
647            );
648            let dt = step.plan.next_poll_s;
649            // Integrate the plant: true drift plus whatever we commanded.
650            err += (base_freq_ppm + step.applied_ppm) * 1e-6 * dt;
651            t += dt;
652        }
653        err
654    }
655
656    #[test]
657    fn the_controller_converges_on_a_drifting_clock() {
658        let residual = closed_loop(40.0, 0.030, 80);
659        assert!(
660            residual.abs() < 1e-4,
661            "did not converge: {residual} s remaining"
662        );
663    }
664
665    #[test]
666    fn it_converges_from_either_direction() {
667        for (drift, start) in [
668            (40.0, 0.030),
669            (-40.0, -0.030),
670            (100.0, -0.050),
671            (-15.0, 0.010),
672        ] {
673            let residual = closed_loop(drift, start, 120);
674            assert!(
675                residual.abs() < 1e-3,
676                "drift {drift} ppm from {start} s left {residual} s"
677            );
678        }
679    }
680
681    #[test]
682    fn frequency_and_drain_stay_separate() {
683        // The distinction this type exists to preserve: after a plan, the
684        // permanent frequency and the temporary drain must be individually
685        // recoverable, not merged.
686        let mut controller = SyncController::new(DisciplineConfig {
687            iburst: false,
688            makestep_threshold: None,
689            ..DisciplineConfig::default()
690        });
691        let step = controller.on_sample(
692            0.0,
693            Sample {
694                t: 0.0,
695                offset: 0.001,
696                delay: 0.0002,
697                dispersion: 0.0,
698            },
699        );
700        assert!(
701            controller.drain_ppm() != 0.0,
702            "a non-zero offset should start a drain"
703        );
704        assert_eq!(
705            step.applied_ppm,
706            controller.freq_ppm() + controller.drain_ppm(),
707            "the applied total must be exactly the two parts"
708        );
709    }
710
711    #[test]
712    fn a_failed_exchange_retries_at_the_burst_spacing_not_the_poll_interval() {
713        // A server that has just started answers unsynchronised, and the client
714        // must refuse those. If the refusal costs a full poll interval, a cold
715        // start waits 16 s for its first usable sample — which is exactly what
716        // the first benchmark against chrony caught.
717        let controller = SyncController::new(DisciplineConfig {
718            min_poll: 4, // 16 s
719            iburst: true,
720            ..DisciplineConfig::default()
721        });
722        let retry = controller.retry_interval_s();
723        assert!(
724            retry <= 4.0,
725            "a cold-start retry waited {retry} s; the burst spacing is the point"
726        );
727    }
728
729    #[test]
730    fn once_the_burst_is_spent_retries_use_the_poll_interval() {
731        // The fast retry is for acquisition only — a synchronised client that
732        // loses a packet must not hammer the server.
733        let mut controller = SyncController::new(DisciplineConfig {
734            min_poll: 4,
735            iburst: true,
736            makestep_threshold: None,
737            ..DisciplineConfig::default()
738        });
739        for i in 0..8 {
740            controller.on_sample(
741                i as f64 * 2.0,
742                Sample {
743                    t: i as f64 * 2.0,
744                    offset: 1e-6,
745                    delay: 0.0002,
746                    dispersion: 0.0,
747                },
748            );
749        }
750        assert!(
751            controller.retry_interval_s() >= 16.0,
752            "after the burst, retries must back off to the poll interval"
753        );
754    }
755
756    #[test]
757    fn a_drain_ends_when_its_budget_is_spent() {
758        // `ClockCommand::Slew` has always carried `drain_offset` -- the size of
759        // the correction. Until drains were budgeted nothing honoured it: the
760        // drain was a frequency that ran until the next plan, so its rate could
761        // only ever be "the offset divided by the poll interval".
762        let mut controller = SyncController::new(DisciplineConfig {
763            iburst: false,
764            makestep_threshold: None,
765            ..DisciplineConfig::default()
766        });
767        controller.on_sample(
768            0.0,
769            Sample {
770                t: 0.0,
771                offset: 0.010,
772                delay: 0.0002,
773                dispersion: 0.0,
774            },
775        );
776        let ends = controller
777            .drain_completes_at()
778            .expect("a drain should be running");
779        assert!(ends > 0.0, "drain has no completion time");
780        // Nothing before then...
781        assert!(controller.poll_drain(ends - 1e-6).is_none());
782        assert!(controller.applied_ppm() != 0.0);
783        // ...and it retires exactly once at the end.
784        assert!(controller.poll_drain(ends).is_some());
785        assert!(controller.poll_drain(ends + 1.0).is_none());
786        assert_eq!(
787            controller.drain_ppm(),
788            0.0,
789            "a spent drain must stop slewing the clock"
790        );
791    }
792
793    #[test]
794    fn a_late_retirement_books_what_the_clock_actually_received() {
795        // The budget says when the drain *should* stop; the driver stops when
796        // it is told to. A caller that wakes late has already had the extra
797        // correction applied, and booking only the budget loses the difference
798        // -- the regression then reads it as drift. Measured on S6, one late
799        // wake-up left a permanent ~180 us offset: 136 us steady against
800        // chrony's 2.5 us.
801        let mut controller = SyncController::new(DisciplineConfig {
802            iburst: false,
803            makestep_threshold: None,
804            ..DisciplineConfig::default()
805        });
806        controller.on_sample(
807            0.0,
808            Sample {
809                t: 0.0,
810                offset: 0.010,
811                delay: 0.0002,
812                dispersion: 0.0,
813            },
814        );
815        let rate = controller.drain_ppm();
816        let ends = controller.drain_completes_at().expect("a drain");
817        let late = 0.5;
818
819        // Retire it half a second late, then feed a sample reporting the clock
820        // as correct. If the extra correction were not booked, the loop would
821        // believe an offset it had already removed.
822        controller.poll_drain(ends + late);
823        let step = controller.on_sample(
824            ends + late,
825            Sample {
826                t: ends + late,
827                offset: 0.0,
828                delay: 0.0002,
829                dispersion: 0.0,
830            },
831        );
832        let overrun = rate.abs() * 1e-6 * late;
833        assert!(
834            overrun > 1e-6,
835            "test is vacuous unless the overrun is meaningful"
836        );
837        assert!(
838            step.estimate_offset_s.abs() < 0.010,
839            "late retirement lost correction the clock had already received:              estimate {} s",
840            step.estimate_offset_s
841        );
842    }
843
844    #[test]
845    fn a_preloaded_frequency_is_the_starting_point() {
846        let mut controller = SyncController::new(DisciplineConfig::default());
847        controller.preload_frequency(-12.5);
848        assert!((controller.freq_ppm() + 12.5).abs() < 1e-12);
849    }
850}