fix(udpscope): bound the reconstructed timeline against the wall clock

Round 3 of the Task 4 review. Three defects, all in FrameDecoder rule 3.

The resync backstop was one-directional. `predicted` is never below
lastEmittedEnd + dt, so rejecting a correction that would step backwards
meant only a LAGGING chain could ever be pulled back; a chain running fast
drifted ahead without bound. Two hosts' crystals differ by tens of ppm, so a
declared SamplingRate is always slightly wrong in one direction or the other
and this is certain on a long session. A leading timeline cannot be corrected
in one burst without going backwards -- lastEmittedEnd is by definition past
arrival -- so the excess is bled off by drawing each burst 10 % narrower until
the timeline is back inside the threshold.

A repeated packet counter was treated as a normal packet. The C client
de-duplicates fragments only, so an unfragmented update reaching a host that
joined the group on two interfaces was emitted twice, doubling the values and
advancing the timeline by a burst that never existed.

The samplingRate == 0 path differenced two HrtRateFit::toSeconds() results.
toSeconds() divides an absolute tick count -- ~1e11 on a producer that has
been up a day -- by a rate refitted on every packet, so its few-parts-in-1e4
wobble arrives multiplied by the whole elapsed epoch: tens of milliseconds of
jitter on a value whose consecutive difference is a few milliseconds. Raw
ticks are differenced instead, anchored on the first usable packet so the
wobble applies only to the interval since attach.

The existing hrt-gap test could not have caught the last one: its 10 ms
producer period made the expected answer exactly kDefaultDt, so a decoder
that derived nothing passed. It now uses 25 ms.

Four tests added, all sabotage-proven. The plan is updated to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-08-27 21:55:33 +02:00
co-authored by Claude Opus 4.6
parent 7102412a9f
commit 3270284cfe
5 changed files with 478 additions and 66 deletions
+9
View File
@@ -71,6 +71,15 @@ public:
* from its own boot, not from the Unix epoch. Pass the result to
* ClockOffset::map() to land it on the wall clock; latching that arbitrary
* epoch difference is precisely what ClockOffset is for.
*
* @warning Never subtract two of these results to measure a short interval.
* The rate is refitted on every add() and wobbles by a few parts in 1e4,
* while hrt is already ~1e11 ticks by the time a scope attaches to a
* long-running producer — so the division carries that relative wobble
* multiplied by the entire elapsed epoch, tens of milliseconds of jitter on
* an absolute value. The jitter is common to both operands only if the rate
* did not change between them, which is exactly what it does. To measure an
* interval, difference the raw ticks and divide once by ticksPerSecond().
*/
double toSeconds(uint64_t hrt) const;
void reset();