fix(udpscope): stop a wrong hrtDt from displacing the trace permanently

On the hrt branch the derived period is not just a spacing: it is the
burst width ClockOffset latches against, so a wrong one shifts the whole
trace by an amount that is usually too small for kRecalibThresholdS to
ever heal. Three routes to a wrong period were open.

Packet loss. elapsed spans every packet since the last one seen, but it
was divided by prevAccCount alone, so a lost datagram scaled the period
by the whole counter gap. Since a burst is anchored on its LAST element,
too wide means it ends in the FUTURE: +22.5 ms for one loss, +225 ms for
ten, at 10 samples per 25 ms packet, mis-spacing 2.7% of all samples at
1% loss. The declared branch already reads the counter for exactly this;
the hrt branch now does too.

Producer restart and reorder. Both leave elapsed at zero, so no period
can be measured -- and the restart packet is also the one that re-latches
after offset.reset(). Falling back to kDefaultDt is only right at 1 kHz;
measured standing displacement was +13.5 ms at 10 samples per 25 ms and
-89 ms at 100 per 10 ms. Remember the last measured period instead.

A stray hrt == 0 packet re-enters the warm-up branch, which spans from
packetBurst's lastPacketWall -- a field the hrt branch never wrote, so it
still held the start of the session. After 153 packets that emitted a
burst 3.8 s in the past, worse the longer the scope had run.

Also: rule 2 with no declared rate stacked every element of the array on
one instant (as UDPSourceSession.cpp:522 does, harmlessly, for a
host-local consumer). Spread it from consecutive time-signal anchors,
which measure the burst on the producer's own clock.

Reverts the previous commit's wallElapsed <= 0 change: it was measurably
inert -- the step floor two lines below already yields the same number --
and its comment claimed a divergence it did not stop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-08-28 06:11:45 +02:00
co-authored by Claude Opus 4.6
parent 3add2c42b9
commit f97fd825c4
4 changed files with 665 additions and 87 deletions
+96 -34
View File
@@ -172,9 +172,36 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
/* Rule 2: anchor from the time signal, spread by the sampling rate. */
if ((d.timeMode == kTimeFirstSample || d.timeMode == kTimeLastSample) &&
hasTimeSig && f.counts[tIdx] >= 1u && f.values[tIdx] != nullptr) {
const double anchor = st.offset.map(f.values[tIdx][0] * tScale, wallNow);
const double rate = DeclaredRate(d.samplingRate);
const double dt = (rate > 0.0) ? (1.0 / rate) : 0.0;
const double prodSec = f.values[tIdx][0] * tScale;
const double anchor = st.offset.map(prodSec, wallNow);
const double rate = DeclaredRate(d.samplingRate);
double dt = (rate > 0.0) ? (1.0 / rate) : 0.0;
/* No rate declared. UDPSourceSession.cpp:522 leaves dt at zero here,
* which stacks every element of the array on one instant — harmless for
* a host-local consumer that only stores them, but this scope's ring,
* decimator and trigger all require a signal's stamps to increase, and a
* plot of N points at one X is not a trace.
*
* The spread is recoverable without a rate: consecutive anchors come
* from the time signal, so their difference is the burst's true duration
* in producer seconds, measured on the producer's own clock rather than
* on arrival — immune to the bursty delivery that corrupts everything
* arrival-derived. Divide by the counter gap for the same reason rule 3
* does: a lost datagram widens the anchor difference without widening
* the array. Until a second packet arrives there is nothing to measure
* and the elements do stack; that is one packet, not the whole run. */
if (!(dt > 0.0) && nElems > 1u && st.prevAnchorValid &&
prodSec > st.prevAnchorProdSec) {
const uint32_t gap = (f.counter != 0u && f.counter > st.lastCounter)
? (f.counter - st.lastCounter) : 1u;
dt = (prodSec - st.prevAnchorProdSec) /
(static_cast<double>(nElems) * static_cast<double>(gap));
}
st.prevAnchorProdSec = prodSec;
st.prevAnchorValid = true;
st.lastCounter = f.counter;
tsOut.resize(nElems);
for (uint32_t e = 0; e < nElems; e++) {
tsOut[e] = (d.timeMode == kTimeFirstSample)
@@ -329,24 +356,24 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
double advance = nominal * factor;
/* A non-positive elapsed means the wall has not moved
* since this signal's previous burst — a coarse arrival
* clock, or two packets stamped within one tick of it.
* There is no wall time to spend, so the cap is zero.
* Skipping the cap in that case (which is what this code
* used to do) hands back the full proportional advance,
* so a run of same-tick arrivals gains lead while no wall
* time passes at all — the divergence the cap exists to
* stop, in its purest form. */
* since this signal's previous burst. Skipping the cap
* then is deliberate and, more to the point, makes no
* difference: forcing the cap to zero instead sends step
* through the floor below to dt * kMinBleedFactor, which
* is the same number the proportional factor already
* yields once the excess exceeds one burst. Both leave
* the same-tick case diverging; only real elapsed wall
* time can bleed lead off, and a recv_time from
* CLOCK_REALTIME (udps_client.c:120) does not repeat. */
const double wallElapsed = wallNow - st.lastEmittedWall;
const double cap = (wallElapsed > 0.0)
? (kWallBleedFraction * wallElapsed)
: 0.0;
if (cap < advance) { advance = cap; }
if (wallElapsed > 0.0) {
const double cap = kWallBleedFraction * wallElapsed;
if (cap < advance) { advance = cap; }
}
step = advance / static_cast<double>(nElems);
/* Reached whenever the cap is zero, and a backstop
* against a nonsensical dt off the wire: downstream
* requires strictly increasing stamps, so the burst must
* still advance by something. */
/* Unreachable with a finite positive dt — kept because
* downstream monotonicity must not depend on that
* argument holding for every value off the wire. */
if (!(step > 0.0)) { step = dt * kMinBleedFactor; }
base = st.lastEmittedEnd + step;
}
@@ -459,11 +486,43 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
}
st.accProdSec += elapsed;
/* The flushes carry contiguous RT cycles, so the gap divided by the
* previous packet's sample count is exactly one cycle period. */
const double hrtDt = (elapsed > 0.0 && st.prevAccCount > 0u)
? (elapsed / static_cast<double>(st.prevAccCount))
: kDefaultDt;
/* The flushes carry contiguous RT cycles, so the tick gap divided by the
* number of cycles it spans is exactly one cycle period. That count is
* NOT prevAccCount: elapsed spans every packet since the last one we
* saw, so a lost datagram makes the tick gap wider without making
* prevAccCount larger. Dividing by prevAccCount alone therefore returns
* a period scaled by the whole counter gap — 2x for one lost datagram,
* 11x for ten — which draws the recovery burst that many times too wide
* and, because the burst is anchored on its LAST element, ends it in the
* FUTURE (measured: +22.5 ms for one loss, +225 ms for ten, at 10
* samples per 25 ms packet). At 1% loss that mis-spaced 2.7% of all
* samples. The declared branch already reads the counter for exactly
* this purpose (`lost`, above); the hrt branch must too.
*
* Only a FORWARD gap counts. A backward or repeated counter is the
* reorder case handled above, where elapsed is zero anyway. */
const uint32_t accGap = (f.counter != 0u && st.lastEmittedValid &&
f.counter > st.lastCounter)
? (f.counter - st.lastCounter) : 1u;
const double cycles = static_cast<double>(st.prevAccCount) *
static_cast<double>(accGap);
/* Falling back to kDefaultDt is a last resort, not a default: see
* SigState::lastHrtDt. The fallback is reached on the first hrt packet
* of a producer restart (elapsed is zero because hrt went backwards) and
* on a reordered datagram, and in both cases the wrong burst width is
* latched into ClockOffset permanently — measured 13.5 ms of standing
* displacement at 10 samples per 25 ms packet, 89 ms at 100 per 10 ms,
* both below kRecalibThresholdS and so never corrected. */
double hrtDt;
if (elapsed > 0.0 && cycles > 0.0) {
hrtDt = elapsed / cycles;
st.lastHrtDt = hrtDt;
} else if (st.lastHrtDt > 0.0) {
hrtDt = st.lastHrtDt;
} else {
hrtDt = kDefaultDt;
}
/* Anchor the burst's LAST element on arrival, not its first. The
* packet's hrt is the tick count of sample 0 (UDPSourceSession.cpp:574),
@@ -495,16 +554,11 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
* at kWallBleedFraction: only that makes the lead bleed off. */
if (st.lastEmittedValid && base <= st.lastEmittedEnd) {
const double wallElapsed = wallNow - st.lastEmittedWall;
/* No wall movement, no wall time to spend: see the same cap in the
* declared branch. Zero rather than "skip the cap", so a run of
* same-tick arrivals cannot advance a full hrtDt per sample while
* the wall stands still. */
const double cap = (wallElapsed > 0.0)
? (kWallBleedFraction * wallElapsed /
static_cast<double>(nElems))
: 0.0;
if (cap < step) { step = cap; }
if (!(step > 0.0)) { step = hrtDt * kMinBleedFactor; }
if (wallElapsed > 0.0) {
const double cap = kWallBleedFraction * wallElapsed /
static_cast<double>(nElems);
if (cap < step) { step = cap; }
}
base = st.lastEmittedEnd + step;
}
@@ -517,6 +571,14 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
st.prevAccCount = nElems;
st.lastEmittedEnd = tsOut[nElems - 1u];
st.lastEmittedWall = wallNow;
/* Keep packetBurst's reference current even though this branch does not
* use it. A single packet with hrt == 0 re-enters the warm-up branch
* above, and packetBurst would otherwise span from whenever this signal
* last took that branch — the whole session. Measured: after 153 hrt
* packets, one zero-hrt packet emitted a burst starting 3.8 s in the
* past, growing without bound with session length. */
st.lastPacketWall = wallNow;
st.lastPacketValid = true;
/* Same duplicate-datagram exposure as the declared branch: a host joined
* on two interfaces receives every unfragmented update twice, and the
* guard at the top of timestamps() can only fire if this branch leaves a