fix(udpscope): make rule 3 converge in both branches and survive reordering
Eight review findings on FrameDecoder's accumulated-scalar rule. The squeeze that pulls a leading timeline back was expressed as a fraction of the NOMINAL burst width, which cannot converge: inside one timestamps() call the wall clock is frozen, so any positive step raises the lead measured at that instant, and the lead only falls because the wall advances between packets. At the kMinBleedFactor floor the timeline still gained 0.05 * nominal per packet, so a declared SamplingRate of 30 against a producer really flushing 10 samples at 1 kHz ran away without bound (667 s of lead after 1000 s of stream). Cap the burst's total advance at half the wall time really elapsed since this signal's previous burst instead, and the lead strictly falls for any declared rate. SigState gained lastEmittedWall for that reference; lastPacketWall could not be reused because it belongs to packetBurst. The hrt branch contributed zero elapsed for a late datagram but still wrote the hrt reference back to it, so the next packet's delta spanned two intervals and fabricated a whole extra packet of producer time — permanently, since the monotonic clamp discards the correction ClockOffset would have made. Reordering is reachable in production: udps_client.c only counts counter gaps. Simply never regressing the reference is not the fix either, because a producer restart would then freeze the signal forever, so the two are now separated by the size of the backward jump. The hrt branch's clamp was also one-directional, reintroducing on that branch exactly the defect the declared branch's squeeze exists to prevent: a backward wall step (NTP, suspend/resume) left a permanent lead. It now shares the same wall-elapsed cap. Also: anchor an hrt-branch burst's LAST element on arrival, matching the declared branch, so two accumulated scalars in one scope do not sit a burst apart on the shared X axis; treat a non-finite samplingRate off the wire as undeclared, since +inf produced a 0.0/0.0 factor the floor could not catch and turned every stamp NaN; write lastCounter on the hrt branch so duplicate datagrams are dropped there too; and correct two comments that argued for the current code with claims that are false (a counter-gap clamp reaches the opposite outcome, not the same one earlier, and the squeeze's steady state is a sawtooth, not a fixed offset). Seven new tests, each proven non-vacuous by sabotage; 54 pass. Plan document Task 4 re-synced and its stale test count and "agree on the same stream" claim corrected.
This commit is contained in:
@@ -30,6 +30,59 @@ static constexpr double kBurstResyncThresholdS = 0.5;
|
||||
*/
|
||||
static constexpr double kMinBleedFactor = 0.05;
|
||||
|
||||
/**
|
||||
* Largest share of the wall time elapsed since a signal's previous burst that
|
||||
* that signal's next burst may advance its own timeline by, while a lead is
|
||||
* being pulled back.
|
||||
*
|
||||
* This, and not kMinBleedFactor, is what makes a lead converge. A fraction of
|
||||
* the NOMINAL burst width cannot: the floor still advances the timeline by
|
||||
* kMinBleedFactor * nominal per packet while the wall advances one packet
|
||||
* interval, so it diverges outright whenever nominal exceeds
|
||||
* (1 / kMinBleedFactor) packet intervals — a declared SamplingRate of 30 against
|
||||
* a producer really flushing 10 samples at 1 kHz put the trace 667 s ahead after
|
||||
* 1000 s of stream. Measuring the allowance against elapsed WALL time instead
|
||||
* bounds the advance below the wall's own advance for any declared rate, so the
|
||||
* lead strictly falls whatever the config says. Any fraction under 1 converges;
|
||||
* a half both converges quickly and leaves the burst visibly compressed rather
|
||||
* than frozen.
|
||||
*/
|
||||
static constexpr double kWallBleedFraction = 0.5;
|
||||
|
||||
/**
|
||||
* A backward jump in producer hrt larger than this is a producer RESTART; a
|
||||
* smaller one is a reordered datagram.
|
||||
*
|
||||
* The two need opposite handling — a reorder must leave the hrt reference
|
||||
* untouched (its interval was already counted by the packet that overtook it),
|
||||
* a restart must rebase onto the new epoch or the signal never advances again —
|
||||
* and nothing but the size of the jump distinguishes them. A second of producer
|
||||
* time is orders of magnitude more than any reordering window a UDP path can
|
||||
* produce (a few packet intervals) and orders of magnitude less than a restart,
|
||||
* which drops hrt from the producer's whole uptime back to near zero.
|
||||
*
|
||||
* Deliberately NOT kBurstResyncThresholdS: that one asks how far a WALL-clock
|
||||
* prediction may sit from arrival, a different quantity in a different clock
|
||||
* that happens to be tuned for delivery jitter. Sharing the number would couple
|
||||
* two unrelated tunings.
|
||||
*/
|
||||
static constexpr double kProducerRestartS = 1.0;
|
||||
|
||||
/**
|
||||
* The declared sampling rate, or 0 when there is none to trust.
|
||||
*
|
||||
* samplingRate arrives unvalidated from a signal descriptor on the wire. A
|
||||
* malformed +inf reaches the reciprocal as dt == 0, which makes a burst's
|
||||
* nominal width zero and the proportional squeeze compute 0.0/0.0 — and a NaN
|
||||
* factor is not caught by the floor, since every comparison against NaN is
|
||||
* false, so the whole burst is emitted as NaN. Rejecting it at the boundary
|
||||
* costs one test and removes the entire class.
|
||||
*/
|
||||
static double DeclaredRate(double samplingRate) {
|
||||
return (std::isfinite(samplingRate) && samplingRate > 0.0) ? samplingRate
|
||||
: 0.0;
|
||||
}
|
||||
|
||||
void FrameDecoder::setSignals(const std::vector<SignalMeta>& signals) {
|
||||
signals_ = signals;
|
||||
state_.assign(signals_.size(), SigState{});
|
||||
@@ -120,7 +173,8 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
|
||||
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 dt = (d.samplingRate > 0.0) ? (1.0 / d.samplingRate) : 0.0;
|
||||
const double rate = DeclaredRate(d.samplingRate);
|
||||
const double dt = (rate > 0.0) ? (1.0 / rate) : 0.0;
|
||||
tsOut.resize(nElems);
|
||||
for (uint32_t e = 0; e < nElems; e++) {
|
||||
tsOut[e] = (d.timeMode == kTimeFirstSample)
|
||||
@@ -151,11 +205,12 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
|
||||
* datagram and reinstated a hole that never existed. A signal that has never
|
||||
* burst is a genuine scalar and is left to rule 5. */
|
||||
if (d.numElements() == 1u && (nElems > 1u || st.lastEmittedValid)) {
|
||||
const double dt = (d.samplingRate > 0.0)
|
||||
? (1.0 / d.samplingRate)
|
||||
: 0.0;
|
||||
/* A rate that is not a finite positive number is no rate at all; see
|
||||
* DeclaredRate(). Such a signal takes the hrt branch below. */
|
||||
const double declared = DeclaredRate(d.samplingRate);
|
||||
const double dt = (declared > 0.0) ? (1.0 / declared) : 0.0;
|
||||
|
||||
if (d.samplingRate > 0.0) {
|
||||
if (declared > 0.0) {
|
||||
/* Where arrival time says this burst begins: its last element was
|
||||
* acquired just before the packet landed. */
|
||||
const double arrivalAnchor =
|
||||
@@ -181,9 +236,23 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
|
||||
* A producer restart or a reordered datagram makes the wrapped
|
||||
* gap enormous, and this deliberately does NOT special-case
|
||||
* that: an absurd gap yields an absurd prediction, which the
|
||||
* arrival backstop below then rejects on its own. Clamping the
|
||||
* gap first would only decide the same question earlier, by a
|
||||
* second rule that no stream can distinguish from this one. */
|
||||
* arrival backstop below then rejects on its own.
|
||||
*
|
||||
* Clamping the gap first is not a harmless earlier version of
|
||||
* the same decision — it reaches the OPPOSITE answer. A clamp
|
||||
* that treats gap > kMaxCounterGap as unknowable has to fall
|
||||
* back to lost == 0, so the prediction becomes
|
||||
* lastEmittedEnd + dt: one sample period after the last burst,
|
||||
* which is exactly the shape of a healthy chain and therefore
|
||||
* lands INSIDE the arrival backstop, is accepted, and silently
|
||||
* closes an outage of arbitrary length. Letting the absurd gap
|
||||
* through produces an absurd prediction that the backstop
|
||||
* catches, and the burst re-anchors on arrival — which is the
|
||||
* right answer, and what
|
||||
* AccumulatedScalarSurvivesAProducerRestart pins down. The
|
||||
* arithmetic cannot overflow: gap and prevAccCount are both
|
||||
* bounded by 2^32-1, so lost is at most ~1.8e19, finite, and
|
||||
* always rejected. */
|
||||
const uint32_t gap = f.counter - st.lastCounter;
|
||||
const double lost = (gap > 1u)
|
||||
? static_cast<double>(gap - 1u) *
|
||||
@@ -223,24 +292,52 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
|
||||
* dated later than the moment this packet landed, so
|
||||
* there is no room to spread into and no burst can end
|
||||
* on arrival without starting before it. Squeeze this
|
||||
* one by exactly the excess instead. That lands its end
|
||||
* one nominal burst ahead of arrival — the closest a
|
||||
* forward-only timeline can legally get — and the excess
|
||||
* settles at (nominal width - true burst period), a
|
||||
* couple of hundred microseconds for the ppm-scale
|
||||
* crystal mismatch that causes this.
|
||||
* one instead, by the excess and by the wall time that
|
||||
* has really elapsed since this signal's last burst.
|
||||
*
|
||||
* The floor keeps the step positive when the excess is
|
||||
* larger than a whole burst (a declared rate that is
|
||||
* wrong by a factor, not by ppm). It only slows the
|
||||
* recovery: each burst then advances by almost nothing
|
||||
* while arrival keeps advancing, so the excess still
|
||||
* falls to zero, just over several packets. */
|
||||
* Both terms are needed, and only the second one
|
||||
* converges. Within this call the wall clock is frozen
|
||||
* at wallNow, so ANY positive step increases the lead
|
||||
* measured at this instant; the lead falls only because
|
||||
* the wall advances BETWEEN packets. A step expressed
|
||||
* purely as a fraction of the nominal burst width
|
||||
* therefore diverges as soon as the nominal width
|
||||
* outruns the packet interval — with the floor alone, a
|
||||
* declared 30 Hz against a producer really flushing 10
|
||||
* samples at 1 kHz gained ~0.67 s of lead per second of
|
||||
* stream, without bound. Capping the burst's total
|
||||
* advance at kWallBleedFraction of the elapsed wall time
|
||||
* makes it advance strictly slower than the wall for any
|
||||
* declared rate, so the lead strictly falls.
|
||||
*
|
||||
* The proportional term still does the fine work: when
|
||||
* the excess is smaller than a burst it removes it in
|
||||
* one packet. kMinBleedFactor only keeps that term
|
||||
* positive when the excess exceeds a whole burst.
|
||||
*
|
||||
* Steady state is a sawtooth, not a fixed offset: the
|
||||
* squeeze pulls the lead down, ordinary chaining resumes
|
||||
* on the very next packet and pushes it back up until
|
||||
* the prediction misses arrival by more than
|
||||
* kBurstResyncThresholdS. So the lead cycles between a
|
||||
* fraction of a millisecond and roughly that threshold —
|
||||
* bounded, which is what matters, but not zero. */
|
||||
const double nominal = static_cast<double>(nElems) * dt;
|
||||
const double excess = st.lastEmittedEnd - wallNow;
|
||||
double factor = 1.0 - excess / nominal;
|
||||
if (factor < kMinBleedFactor) { factor = kMinBleedFactor; }
|
||||
step = dt * factor;
|
||||
double advance = nominal * factor;
|
||||
|
||||
const double wallElapsed = wallNow - st.lastEmittedWall;
|
||||
if (wallElapsed > 0.0) {
|
||||
const double cap = kWallBleedFraction * wallElapsed;
|
||||
if (cap < advance) { advance = cap; }
|
||||
}
|
||||
step = advance / static_cast<double>(nElems);
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -250,6 +347,7 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
|
||||
tsOut[e] = base + static_cast<double>(e) * step;
|
||||
}
|
||||
st.lastEmittedEnd = tsOut[nElems - 1u];
|
||||
st.lastEmittedWall = wallNow;
|
||||
st.lastCounter = f.counter;
|
||||
st.prevAccCount = nElems;
|
||||
st.lastEmittedValid = true;
|
||||
@@ -279,11 +377,47 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
|
||||
* arbitrary epoch that leaves behind, exactly as it would have latched
|
||||
* the producer's boot epoch. */
|
||||
double elapsed = 0.0;
|
||||
if (st.lastAccValid && f.hrt > st.lastAccHrt) {
|
||||
elapsed = static_cast<double>(f.hrt - st.lastAccHrt) / rate;
|
||||
/* Whether lastAccHrt should take this packet's value. Only a packet
|
||||
* that legitimately defines the new front of producer time may move it;
|
||||
* see the backward case below. */
|
||||
bool takeHrt = true;
|
||||
if (st.lastAccValid) {
|
||||
if (f.hrt > st.lastAccHrt) {
|
||||
elapsed = static_cast<double>(f.hrt - st.lastAccHrt) / rate;
|
||||
} else {
|
||||
/* hrt went backwards. Two entirely different events look like
|
||||
* this and only the SIZE of the jump separates them.
|
||||
*
|
||||
* A small one is a reordered datagram: the packet that overtook
|
||||
* it already counted the interval it covers, so it must
|
||||
* contribute nothing — and must also leave lastAccHrt alone.
|
||||
* Letting it write lastAccHrt anyway (which is what this code
|
||||
* used to do unconditionally) rolls the reference back one
|
||||
* interval, so the NEXT packet's delta spans two and fabricates
|
||||
* a whole extra packet of producer time. It never heals:
|
||||
* ClockOffset would correct it, but the monotonic clamp below
|
||||
* discards every backward correction. A hundred swaps on a
|
||||
* 25 ms stream left the trace 3.5 s ahead, permanently. The C
|
||||
* client does not reorder for us — udps_client.c only COUNTS
|
||||
* counter gaps — so this is reachable on any real network.
|
||||
*
|
||||
* A large one is a producer restart: hrt drops from the
|
||||
* producer's whole uptime to near zero. Here the unconditional
|
||||
* write was the right behaviour and must be kept, because
|
||||
* refusing to regress would leave every subsequent packet below
|
||||
* lastAccHrt forever, elapsed permanently zero and the signal
|
||||
* frozen. Rebase, and reset the offset so it re-latches against
|
||||
* the new epoch instead of being dragged there by recalibration. */
|
||||
const double backward =
|
||||
static_cast<double>(st.lastAccHrt - f.hrt) / rate;
|
||||
if (backward > kProducerRestartS) {
|
||||
st.offset.reset();
|
||||
} else {
|
||||
takeHrt = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
st.accProdSec += elapsed;
|
||||
double base = st.offset.map(st.accProdSec, wallNow);
|
||||
|
||||
/* The flushes carry contiguous RT cycles, so the gap divided by the
|
||||
* previous packet's sample count is exactly one cycle period. */
|
||||
@@ -291,21 +425,58 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
|
||||
? (elapsed / static_cast<double>(st.prevAccCount))
|
||||
: 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),
|
||||
* so stepping forward from it is right — but ClockOffset latches
|
||||
* offset = wall - producerSec on its first call, and passing the raw
|
||||
* arrival would put sample 0 at the instant the packet carrying the
|
||||
* whole burst LANDED, dating every sample in it late by a burst. The
|
||||
* declared-rate branch above already anchors on the burst end
|
||||
* (arrivalAnchor), and two accumulated scalars in one scope, one with a
|
||||
* declared rate and one without, would otherwise sit a burst apart on a
|
||||
* shared X axis — 9 ms for 10 samples at 1 kHz, plain to see at a 200 ms
|
||||
* window. Since map() latches once, this is a constant shift applied at
|
||||
* latch and recalibration only; it changes no spacing. */
|
||||
double base = st.offset.map(
|
||||
st.accProdSec,
|
||||
wallNow - static_cast<double>(nElems - 1u) * hrtDt);
|
||||
double step = hrtDt;
|
||||
|
||||
/* ClockOffset recalibrates once true drift passes its threshold, and a
|
||||
* recalibration can land behind where this signal already is.
|
||||
* Downstream requires increasing stamps, so step forward minimally. */
|
||||
* Downstream requires increasing stamps, so step forward minimally —
|
||||
* but a bare forward step is one-directional, exactly the defect the
|
||||
* declared branch's squeeze exists to avoid. A backward wall step (an
|
||||
* NTP correction, a suspend/resume) would otherwise leave this signal
|
||||
* permanently ahead of the wall clock, since the recalibrated base is
|
||||
* behind lastEmittedEnd on every later packet too and the clamp keeps
|
||||
* discarding it. So cap the burst's total advance against the wall time
|
||||
* elapsed since this signal's previous burst, for the reason spelled out
|
||||
* at kWallBleedFraction: only that makes the lead bleed off. */
|
||||
if (st.lastEmittedValid && base <= st.lastEmittedEnd) {
|
||||
base = st.lastEmittedEnd + hrtDt;
|
||||
const double wallElapsed = wallNow - st.lastEmittedWall;
|
||||
if (wallElapsed > 0.0) {
|
||||
const double cap = kWallBleedFraction * wallElapsed /
|
||||
static_cast<double>(nElems);
|
||||
if (cap < step) { step = cap; }
|
||||
}
|
||||
base = st.lastEmittedEnd + step;
|
||||
}
|
||||
|
||||
tsOut.resize(nElems);
|
||||
for (uint32_t e = 0; e < nElems; e++) {
|
||||
tsOut[e] = base + static_cast<double>(e) * hrtDt;
|
||||
tsOut[e] = base + static_cast<double>(e) * step;
|
||||
}
|
||||
st.lastAccHrt = f.hrt;
|
||||
if (takeHrt) { st.lastAccHrt = f.hrt; }
|
||||
st.lastAccValid = true;
|
||||
st.prevAccCount = nElems;
|
||||
st.lastEmittedEnd = tsOut[nElems - 1u];
|
||||
st.lastEmittedWall = wallNow;
|
||||
/* 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
|
||||
* counter behind for it to compare against. */
|
||||
st.lastCounter = f.counter;
|
||||
st.lastEmittedValid = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -10,17 +10,30 @@
|
||||
* Source/Applications/StreamHub/UDPSourceSession.cpp documents this failure and
|
||||
* solves it; these are the same rules, computed from udps_frame_t's own fields.
|
||||
*
|
||||
* One rule deliberately differs. StreamHub anchors every accumulated-scalar
|
||||
* burst on the packet's own hrt, converted with the LOCAL MARTe
|
||||
* HighResolutionTimer frequency — correct only because StreamHub runs on the
|
||||
* producer's host. A bench scope attaches over the network and has no access to
|
||||
* that frequency; it can only regress hrt against arrival time, which is
|
||||
* exactly what the bursty delivery above corrupts. So when a SamplingRate is
|
||||
* declared this decoder chains bursts instead, using the packet counter to
|
||||
* account for loss and arrival time only as a backstop. The consequence is that
|
||||
* a declared rate measured against the producer's crystal rather than ours makes
|
||||
* the reconstructed timeline drift, and drift that only arrival time can
|
||||
* observe must be corrected against arrival time — see rule 3.
|
||||
* Two rules deliberately differ, both in the accumulated-scalar case (rule 3).
|
||||
*
|
||||
* First, the anchor. StreamHub anchors every accumulated-scalar burst on the
|
||||
* packet's own hrt, converted with the LOCAL MARTe HighResolutionTimer
|
||||
* frequency — correct only because StreamHub runs on the producer's host. A
|
||||
* bench scope attaches over the network and has no access to that frequency; it
|
||||
* can only regress hrt against arrival time, which is exactly what the bursty
|
||||
* delivery above corrupts. So when a SamplingRate is declared this decoder
|
||||
* chains bursts instead, using the packet counter to account for loss and
|
||||
* arrival time only as a backstop. The consequence is that a declared rate
|
||||
* measured against the producer's crystal rather than ours makes the
|
||||
* reconstructed timeline drift, and drift that only arrival time can observe
|
||||
* must be corrected against arrival time — see rule 3.
|
||||
*
|
||||
* Second, the entry condition. UDPSourceSession.cpp:554 routes any update
|
||||
* carrying nElems <= 1 to plain arrival time. That is safe for a host-local
|
||||
* consumer whose arrival time is the producer's own clock, but wrong here:
|
||||
* Accumulate mode flushes on a TIMER, so a short RT cycle legitimately delivers
|
||||
* a single sample between two full bursts. Dating that one sample from arrival
|
||||
* while its neighbours are chained puts it off the chain, and — worse — leaves
|
||||
* lastCounter behind, so the next full burst reads the skipped counter as a lost
|
||||
* datagram and reinstates a hole that never existed. So a signal that has
|
||||
* already burst keeps every later update on rule 3 regardless of its length; a
|
||||
* signal that has never burst is a genuine scalar and is left to rule 5.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
@@ -71,12 +84,22 @@ private:
|
||||
double accProdSec = 0.0;
|
||||
bool lastAccValid = false;
|
||||
uint32_t prevAccCount = 0;
|
||||
/** For accumulated scalars with a declared sampling rate: end timestamp
|
||||
* of the most recently emitted burst, and the packet counter it came
|
||||
* from. The next burst is chained onto that end, with the counter gap
|
||||
* reinstating the exact duration of any lost datagrams. */
|
||||
/** For accumulated scalars (rule 3, either branch): end timestamp of the
|
||||
* most recently emitted burst, and the packet counter it came from. The
|
||||
* next burst is chained onto that end, with the counter gap reinstating
|
||||
* the exact duration of any lost datagrams. */
|
||||
double lastEmittedEnd = 0.0;
|
||||
uint32_t lastCounter = 0u;
|
||||
/** ARRIVAL time of the packet that produced lastEmittedEnd. Valid
|
||||
* exactly when lastEmittedValid is, so it needs no flag of its own.
|
||||
* Deliberately not lastPacketWall, which belongs to packetBurst() and
|
||||
* is updated on frames rule 3 never emits. This is the only reference
|
||||
* against which a leading timeline can be pulled back: the correction
|
||||
* has to be expressed as a fraction of the wall time that has really
|
||||
* elapsed since this signal's previous burst, because within a single
|
||||
* timestamps() call the wall clock is frozen and every forward step,
|
||||
* however small, increases the lead measured at that instant. */
|
||||
double lastEmittedWall = 0.0;
|
||||
bool lastEmittedValid = false;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
|
||||
using namespace udpscope;
|
||||
@@ -346,6 +348,91 @@ TEST(FrameDecoder, AccumulatedScalarDoesNotDriftAwayFromTheWallClockForever) {
|
||||
EXPECT_LT(worstLead, 0.6) << "timeline drifted " << worstLead << " s ahead";
|
||||
}
|
||||
|
||||
// The test above only exercises a rate that is wrong by ppm, where the squeeze's
|
||||
// proportional term does all the work. A rate wrong by a FACTOR is the case the
|
||||
// kMinBleedFactor floor cannot handle on its own: at the floor the timeline
|
||||
// still advances kMinBleedFactor * nominal per packet, so whenever the nominal
|
||||
// burst is wider than 1/kMinBleedFactor packet intervals the lead grows without
|
||||
// bound rather than bleeding off (measured: 27 s of lead after 40 s of stream,
|
||||
// 667 s after 1000 s). Only capping the advance against the wall time really
|
||||
// elapsed since this signal's previous burst converges for every declared rate.
|
||||
TEST(FrameDecoder, AccumulatedScalarConvergesWhenTheDeclaredRateIsFarTooLow) {
|
||||
FrameDecoder dec;
|
||||
SignalMeta m = accSignal();
|
||||
m.samplingRate = 30.0; /* config says 30 Hz... */
|
||||
dec.setSignals({m});
|
||||
|
||||
/* ...while the producer really flushes 10 samples at 1 kHz, so a packet is
|
||||
* 10 ms of wall time and 333 ms of nominal, declared time. */
|
||||
double worstLead = 0.0;
|
||||
double last = 0.0;
|
||||
for (int p = 0; p < 5000; p++) { /* 50 s of stream */
|
||||
FrameBuilder fb;
|
||||
fb.addSignal(std::vector<double>(10, 1.0));
|
||||
const double arrival = 500.0 + p * 0.010;
|
||||
const FrameView& f =
|
||||
fb.build(0, arrival, 10, static_cast<uint32_t>(p + 1));
|
||||
dec.beginFrame(f);
|
||||
std::vector<double> ts;
|
||||
ASSERT_TRUE(dec.timestamps(f, 0, ts));
|
||||
for (size_t i = 0; i < ts.size(); i++) {
|
||||
ASSERT_GT(ts[i], last) << "timeline went backwards at packet " << p;
|
||||
last = ts[i];
|
||||
}
|
||||
worstLead = std::max(worstLead, ts[9] - arrival);
|
||||
}
|
||||
|
||||
/* Bounded, not zero: normal chaining resumes the moment the squeeze stops,
|
||||
* so the lead sawtooths up to about kBurstResyncThresholdS and back. */
|
||||
EXPECT_LT(worstLead, 1.0) << "timeline ran " << worstLead << " s ahead";
|
||||
}
|
||||
|
||||
// samplingRate is unvalidated wire data. A malformed +inf makes the declared
|
||||
// period zero, so a burst's nominal width is zero and the proportional squeeze
|
||||
// evaluates 0.0/0.0 — and a NaN factor slips past the floor, because every
|
||||
// comparison against NaN is false. The burst, and then every burst after it,
|
||||
// comes out NaN. Treating a non-finite rate as no rate at all removes the class.
|
||||
TEST(FrameDecoder, AccumulatedScalarWithANonFiniteRateFallsBackToTheHrtPath) {
|
||||
FrameDecoder dec;
|
||||
SignalMeta m = accSignal();
|
||||
m.samplingRate = std::numeric_limits<double>::infinity();
|
||||
dec.setSignals({m});
|
||||
|
||||
const double ticks = 1.0e9;
|
||||
std::vector<double> last;
|
||||
for (int p = 0; p < 60; p++) {
|
||||
FrameBuilder fb;
|
||||
fb.addSignal(std::vector<double>(10, 1.0));
|
||||
const double producerSec = 100.0 + p * 0.025;
|
||||
/* Packet 40 lands at exactly the same instant as packet 39. With the
|
||||
* degenerate zero period the previous burst ends precisely on its own
|
||||
* arrival, so this makes the squeeze's excess exactly zero — the 0.0/0.0
|
||||
* that produces the NaN. */
|
||||
const int q = (p == 40) ? 39 : p;
|
||||
/* Zero-mean jitter so the answer also identifies WHICH branch replied:
|
||||
* a degenerate declared branch spans the jittered arrival gap, the hrt
|
||||
* branch returns the producer's exact 2.5 ms whatever delivery did. */
|
||||
const double jitter[4] = {0.0, 0.003, 0.0, -0.003};
|
||||
const double arrival = 700.0 + q * 0.025 + jitter[q % 4];
|
||||
const FrameView& f =
|
||||
fb.build(static_cast<uint64_t>(producerSec * ticks), arrival, 10,
|
||||
static_cast<uint32_t>(p + 1));
|
||||
dec.beginFrame(f);
|
||||
std::vector<double> ts;
|
||||
if (dec.timestamps(f, 0, ts)) {
|
||||
for (size_t i = 0; i < ts.size(); i++) {
|
||||
ASSERT_TRUE(std::isfinite(ts[i]))
|
||||
<< "packet " << p << " element " << i;
|
||||
}
|
||||
last = ts;
|
||||
}
|
||||
}
|
||||
|
||||
ASSERT_EQ(last.size(), 10u);
|
||||
EXPECT_NEAR(last[1] - last[0], 0.0025, 2e-5)
|
||||
<< "an unusable declared rate must fall through to the hrt path";
|
||||
}
|
||||
|
||||
// The C client de-duplicates fragments but not whole unfragmented updates, so a
|
||||
// host subscribed on two interfaces sees each datagram twice. Emitting the
|
||||
// repeat would double the values and advance time by a burst that never was.
|
||||
@@ -367,7 +454,7 @@ TEST(FrameDecoder, AccumulatedScalarDropsADuplicatedDatagram) {
|
||||
const FrameView& next = fb.build(0, 500.109, 10, 11u);
|
||||
dec.beginFrame(next);
|
||||
ASSERT_TRUE(dec.timestamps(next, 0, ts));
|
||||
EXPECT_NEAR(ts[0], endBefore + 0.001, 1e-9);
|
||||
EXPECT_NEAR(ts[0], endBefore + 0.001, 1e-6);
|
||||
}
|
||||
|
||||
// A producer restart returns the counter to zero mid-stream. The unsigned gap
|
||||
@@ -514,6 +601,258 @@ TEST(FrameDecoder, AccumulatedScalarStaysMonotonicOnALongUndeclaredRunAfterBoot)
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
/** One delivered datagram of an undeclared-rate accumulated scalar. */
|
||||
struct HrtPacket {
|
||||
uint64_t hrt;
|
||||
double arrival;
|
||||
uint32_t counter;
|
||||
};
|
||||
|
||||
SignalMeta undeclaredAcc() {
|
||||
SignalMeta m;
|
||||
m.name = "Acc";
|
||||
m.typeCode = 9;
|
||||
m.numRows = 1;
|
||||
m.samplingRate = 0.0; /* undeclared: the hrt branch */
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Runs a delivery schedule of 10-sample bursts; returns the last stamp emitted. */
|
||||
double runUndeclared(const std::vector<HrtPacket>& pkts) {
|
||||
FrameDecoder dec;
|
||||
dec.setSignals({undeclaredAcc()});
|
||||
double lastTs = 0.0;
|
||||
for (const HrtPacket& p : pkts) {
|
||||
FrameBuilder fb;
|
||||
fb.addSignal(std::vector<double>(10, 1.0));
|
||||
const FrameView& f = fb.build(p.hrt, p.arrival, 10, p.counter);
|
||||
dec.beginFrame(f);
|
||||
std::vector<double> ts;
|
||||
if (dec.timestamps(f, 0, ts)) { lastTs = ts.back(); }
|
||||
}
|
||||
return lastTs;
|
||||
}
|
||||
|
||||
/** 300 clean packets, 25 ms apart, from a producer that has been up a day. */
|
||||
std::vector<HrtPacket> cleanUndeclaredStream() {
|
||||
const double ticks = 1.0e9;
|
||||
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
|
||||
std::vector<HrtPacket> pkts;
|
||||
for (int p = 0; p < 300; p++) {
|
||||
pkts.push_back(HrtPacket{
|
||||
bootHrt + static_cast<uint64_t>(p * 0.025 * ticks),
|
||||
700.0 + p * 0.025,
|
||||
static_cast<uint32_t>(p + 1)});
|
||||
}
|
||||
return pkts;
|
||||
}
|
||||
|
||||
} /* namespace */
|
||||
|
||||
// A datagram that overtakes its neighbour arrives with an hrt BEHIND the one
|
||||
// already recorded. It must contribute no producer time — the packet that
|
||||
// overtook it already counted the interval — and it must also leave the hrt
|
||||
// reference alone. Writing the reference back is what the code used to do, and
|
||||
// it makes the NEXT packet's delta span two intervals, fabricating a whole extra
|
||||
// packet of producer time per reorder. That error never heals: ClockOffset would
|
||||
// correct it but the monotonic clamp discards every backward correction.
|
||||
TEST(FrameDecoder, UndeclaredAccumulatedScalarIgnoresReorderedDatagrams) {
|
||||
const std::vector<HrtPacket> clean = cleanUndeclaredStream();
|
||||
|
||||
/* Ten swaps: each pair is delivered in the opposite order, so the arrival
|
||||
* times stay increasing (delivery order is what the socket saw) while the
|
||||
* hrt and counter they carry are exchanged. */
|
||||
std::vector<HrtPacket> reordered = clean;
|
||||
for (int k = 100; k < 200; k += 10) {
|
||||
std::swap(reordered[k].hrt, reordered[k + 1].hrt);
|
||||
std::swap(reordered[k].counter, reordered[k + 1].counter);
|
||||
}
|
||||
|
||||
const double cleanEnd = runUndeclared(clean);
|
||||
const double reorderedEnd = runUndeclared(reordered);
|
||||
|
||||
/* Each swap used to add about one packet of producer time (25 ms); ten of
|
||||
* them left the trace a quarter of a second ahead, for good. */
|
||||
EXPECT_NEAR(reorderedEnd, cleanEnd, 1.0e-3)
|
||||
<< "reordering left " << (reorderedEnd - cleanEnd) << " s of offset";
|
||||
}
|
||||
|
||||
// The counterweight. A producer restart drops hrt from the machine's whole
|
||||
// uptime back to near zero, and that is the one case where the hrt reference
|
||||
// MUST be allowed to regress: refusing every backward step would leave each
|
||||
// later packet below the reference forever, the elapsed producer time
|
||||
// permanently zero, and the signal frozen at the fallback period.
|
||||
TEST(FrameDecoder, UndeclaredAccumulatedScalarSurvivesAProducerRestart) {
|
||||
FrameDecoder dec;
|
||||
dec.setSignals({undeclaredAcc()});
|
||||
|
||||
const double ticks = 1.0e9;
|
||||
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
|
||||
double last = 0.0;
|
||||
for (int p = 0; p < 120; p++) {
|
||||
const bool restarted = (p >= 60);
|
||||
/* After the restart hrt counts from one second of uptime, and the
|
||||
* outage cost two seconds of wall time. */
|
||||
const uint64_t hrt = restarted
|
||||
? static_cast<uint64_t>((1.0 + (p - 60) * 0.025) * ticks)
|
||||
: bootHrt + static_cast<uint64_t>(p * 0.025 * ticks);
|
||||
const double arrival = restarted
|
||||
? (700.0 + 59 * 0.025 + 2.0 + (p - 60) * 0.025)
|
||||
: (700.0 + p * 0.025);
|
||||
|
||||
FrameBuilder fb;
|
||||
fb.addSignal(std::vector<double>(10, 1.0));
|
||||
const FrameView& f =
|
||||
fb.build(hrt, arrival, 10, static_cast<uint32_t>(p + 1));
|
||||
dec.beginFrame(f);
|
||||
std::vector<double> ts;
|
||||
if (!dec.timestamps(f, 0, ts)) {
|
||||
/* Only the very first packet, which has no previous arrival for the
|
||||
* pre-fit fallback to span from. */
|
||||
ASSERT_EQ(p, 0) << "packet " << p << " produced nothing";
|
||||
continue;
|
||||
}
|
||||
for (size_t i = 0; i < ts.size(); i++) {
|
||||
ASSERT_GT(ts[i], last) << "timeline went backwards at packet " << p;
|
||||
last = ts[i];
|
||||
}
|
||||
/* The restart packet itself has no measurable interval and falls back to
|
||||
* the default period; from the next one on the producer's own 2.5 ms
|
||||
* must be back. A decoder that could not regress the reference would sit
|
||||
* at the 1 ms fallback for the rest of the run. */
|
||||
if (p >= 62) {
|
||||
EXPECT_NEAR(ts[1] - ts[0], 0.0025, 1e-5)
|
||||
<< "spacing not recovered at packet " << p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The hrt branch's clamp used to be one-directional, which is the same defect
|
||||
// the declared branch's squeeze exists to prevent. A wall clock that steps
|
||||
// BACKWARDS — an NTP correction, a suspend/resume — leaves the emitted timeline
|
||||
// permanently ahead, because the recalibrated position is behind lastEmittedEnd
|
||||
// on every later packet too and the clamp keeps discarding it.
|
||||
TEST(FrameDecoder, UndeclaredAccumulatedScalarRecoversFromABackwardWallStep) {
|
||||
FrameDecoder dec;
|
||||
dec.setSignals({undeclaredAcc()});
|
||||
|
||||
const double ticks = 1.0e9;
|
||||
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
|
||||
double last = 0.0;
|
||||
double lead = 0.0;
|
||||
double worstAfter = 0.0;
|
||||
/* 100 ms packets of 10 samples. The step is 0.6 s — just past
|
||||
* ClockOffset::kRecalibThresholdS, which is what makes the recalibrated
|
||||
* position land behind lastEmittedEnd and the clamp fire at all — and it
|
||||
* comes after the rate fit's 256-sample window is full, so the fit
|
||||
* redistributes it slowly enough not to be mistaken for this recovery. */
|
||||
for (int p = 0; p < 340; p++) {
|
||||
const uint64_t hrt = bootHrt + static_cast<uint64_t>(p * 0.1 * ticks);
|
||||
const double arrival = 700.0 + p * 0.1 - ((p >= 300) ? 0.6 : 0.0);
|
||||
|
||||
FrameBuilder fb;
|
||||
fb.addSignal(std::vector<double>(10, 1.0));
|
||||
const FrameView& f =
|
||||
fb.build(hrt, arrival, 10, static_cast<uint32_t>(p + 1));
|
||||
dec.beginFrame(f);
|
||||
std::vector<double> ts;
|
||||
if (!dec.timestamps(f, 0, ts)) {
|
||||
ASSERT_EQ(p, 0) << "packet " << p << " produced nothing";
|
||||
continue;
|
||||
}
|
||||
for (size_t i = 0; i < ts.size(); i++) {
|
||||
ASSERT_GT(ts[i], last) << "timeline went backwards at packet " << p;
|
||||
last = ts[i];
|
||||
}
|
||||
lead = ts.back() - arrival;
|
||||
/* Twenty packets is a generous allowance: the cap bleeds half a packet
|
||||
* interval per packet, so the 0.6 s step is gone in twelve. */
|
||||
if (p >= 320) { worstAfter = std::max(worstAfter, std::fabs(lead)); }
|
||||
}
|
||||
|
||||
EXPECT_LT(worstAfter, 0.1)
|
||||
<< "still " << worstAfter << " s from the wall clock long after the step";
|
||||
}
|
||||
|
||||
// The two branches must place a burst the same way round or two accumulated
|
||||
// scalars in one scope, one with a declared rate and one without, sit a whole
|
||||
// burst apart on the shared X axis. The declared branch anchors the LAST element
|
||||
// on arrival, which is right: the samples were acquired before the packet
|
||||
// carrying them landed. The hrt branch used to latch its offset against raw
|
||||
// arrival, putting the FIRST element there instead.
|
||||
TEST(FrameDecoder, UndeclaredAccumulatedScalarEndsItsBurstOnArrival) {
|
||||
FrameDecoder dec;
|
||||
dec.setSignals({undeclaredAcc()});
|
||||
|
||||
const double ticks = 1.0e9;
|
||||
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
|
||||
/* 10 ms per packet of 10 samples, so the derived period is 1 ms — equal to
|
||||
* the fallback the very first hrt-branch packet has to use, which is what
|
||||
* ClockOffset latches against. Any other period would bake that one packet's
|
||||
* fallback into the offset and blur the convention this test is pinning. */
|
||||
double lastArrival = 0.0;
|
||||
std::vector<double> last;
|
||||
for (int p = 0; p < 60; p++) {
|
||||
FrameBuilder fb;
|
||||
fb.addSignal(std::vector<double>(10, 1.0));
|
||||
const uint64_t hrt = bootHrt + static_cast<uint64_t>(p * 0.010 * ticks);
|
||||
const double arrival = 700.0 + p * 0.010;
|
||||
const FrameView& f =
|
||||
fb.build(hrt, arrival, 10, static_cast<uint32_t>(p + 1));
|
||||
dec.beginFrame(f);
|
||||
std::vector<double> ts;
|
||||
if (dec.timestamps(f, 0, ts)) { last = ts; lastArrival = arrival; }
|
||||
}
|
||||
|
||||
ASSERT_EQ(last.size(), 10u);
|
||||
EXPECT_NEAR(last[9], lastArrival, 1e-9) << "burst must END on arrival";
|
||||
EXPECT_NEAR(last[0], lastArrival - 0.009, 1e-9);
|
||||
}
|
||||
|
||||
// The same double delivery that the declared branch guards against — a host
|
||||
// joined on two interfaces receives every unfragmented update twice — reaches an
|
||||
// undeclared-rate signal identically. The guard can only fire if this branch
|
||||
// leaves a counter behind for it to compare against.
|
||||
TEST(FrameDecoder, UndeclaredAccumulatedScalarDropsADuplicatedDatagram) {
|
||||
FrameDecoder dec;
|
||||
dec.setSignals({undeclaredAcc()});
|
||||
|
||||
const double ticks = 1.0e9;
|
||||
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
|
||||
std::vector<double> ts;
|
||||
for (int p = 0; p < 50; p++) {
|
||||
FrameBuilder fb;
|
||||
fb.addSignal(std::vector<double>(10, 1.0));
|
||||
const FrameView& f =
|
||||
fb.build(bootHrt + static_cast<uint64_t>(p * 0.010 * ticks),
|
||||
700.0 + p * 0.010, 10, static_cast<uint32_t>(p + 1));
|
||||
dec.beginFrame(f);
|
||||
const bool ok = dec.timestamps(f, 0, ts);
|
||||
ASSERT_EQ(ok, p != 0) << "at packet " << p;
|
||||
}
|
||||
const double endBefore = ts[9];
|
||||
|
||||
/* Counter 50 again, the same update off the second interface. */
|
||||
FrameBuilder fb;
|
||||
fb.addSignal(std::vector<double>(10, 1.0));
|
||||
const FrameView& dup =
|
||||
fb.build(bootHrt + static_cast<uint64_t>(49 * 0.010 * ticks),
|
||||
700.0 + 49 * 0.010 + 0.0001, 10, 50u);
|
||||
dec.beginFrame(dup);
|
||||
EXPECT_FALSE(dec.timestamps(dup, 0, ts)) << "duplicate was emitted twice";
|
||||
|
||||
/* And the drop left the chain alone: the genuine next update still lands one
|
||||
* period after the last burst ended. */
|
||||
const FrameView& next =
|
||||
fb.build(bootHrt + static_cast<uint64_t>(50 * 0.010 * ticks),
|
||||
700.0 + 50 * 0.010, 10, 51u);
|
||||
dec.beginFrame(next);
|
||||
ASSERT_TRUE(dec.timestamps(next, 0, ts));
|
||||
EXPECT_NEAR(ts[0], endBefore + 0.001, 1e-6);
|
||||
}
|
||||
|
||||
// A PACKET burst has no per-element time at all. Elements span
|
||||
// (lastPacket, thisPacket] — backwards from arrival, because the samples were
|
||||
// acquired before the packet landed. Forward extrapolation would let a jittered
|
||||
|
||||
Reference in New Issue
Block a user