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}