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:
Martino Ferrari
2026-08-27 22:43:27 +02:00
parent a2efc142c3
commit 440b805afd
4 changed files with 1317 additions and 166 deletions
+340 -1
View File
@@ -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