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}