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
+33 -1
View File
@@ -10,7 +10,17 @@
* Source/Applications/StreamHub/UDPSourceSession.cpp documents this failure and
* solves it; these are the same rules, computed from udps_frame_t's own fields.
*
* Three rules deliberately differ, all in the accumulated-scalar case (rule 3).
* They are NOT the same code, and the differences are not a short list. Every
* one of them comes from the same root: StreamHub runs on the producer's host,
* so its arrival time IS the producer's clock and its local
* HighResolutionTimer::Frequency() IS the frequency behind the packet's hrt.
* Neither holds over a network, so anything StreamHub can read directly this
* decoder has to estimate (HrtRateFit, ClockOffset), and anything it estimates
* it must also defend — hence the monotonic clamps, the kWallBleedFraction
* bleed, the reorder and restart guards and the duplicate-datagram drop, none of
* which exist in UDPSourceSession.cpp. Do not read the three sections below as
* exhaustive; they are the three that change where a sample LANDS, and so the
* three worth checking first when a trace looks wrong.
*
* First, the anchor. StreamHub anchors every accumulated-scalar burst on the
* packet's own hrt, converted with the LOCAL MARTe HighResolutionTimer
@@ -42,6 +52,15 @@
* 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.
*
* Two divergences OUTSIDE rule 3 are known and deliberately left as they are.
* Rule 1 keys ClockOffset on the consuming signal, where UDPSourceSession.cpp:516
* keys it on the time-signal index, so signals sharing a time signal share an
* offset there and not here — immaterial, since the mapping they compute is the
* same. And a FIRST_SAMPLE/LAST_SAMPLE signal whose time signal is absent falls
* through to rule 4 rather than using its declared rate; that is a malformed
* CONFIG, and spanning arrivals is the more honest answer than trusting a rate
* whose anchor never arrived.
*/
#pragma once
@@ -92,6 +111,19 @@ private:
double accProdSec = 0.0;
bool lastAccValid = false;
uint32_t prevAccCount = 0;
/** Last inter-element period the hrt branch actually MEASURED, used
* whenever this packet cannot measure one of its own (no previous tick,
* or hrt went backwards). The constant kDefaultDt is a poor substitute:
* it is only right at 1 kHz, and a wrong period here is not merely a
* wrong spacing for one burst — it is the burst width ClockOffset
* latches against, and the resulting displacement is usually too small
* for kRecalibThresholdS to ever heal. Zero until first measured. */
double lastHrtDt = 0.0;
/** Rule 2 only: the previous packet's time-signal anchor, in PRODUCER
* seconds. Consecutive anchors are what lets an array with no declared
* sampling rate be spread at all. */
double prevAnchorProdSec = 0.0;
bool prevAnchorValid = false;
/** 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
+198 -8
View File
@@ -107,6 +107,49 @@ TEST(FrameDecoder, FirstSampleAnchorsElementZeroAndCountsForward) {
EXPECT_NEAR(ts[3], 2000.003, 1e-9);
}
// With no declared rate there is nothing to spread the array by, and
// UDPSourceSession.cpp:522 leaves the step at zero — every element of the array
// on one instant. A host-local consumer only stores them; this scope's ring,
// decimator and trigger all require increasing stamps, and N points at one X is
// not a trace. Consecutive time-signal anchors carry the burst duration on the
// PRODUCER'S clock, so the spread is recoverable without a rate.
TEST(FrameDecoder, FirstSampleWithNoRateSpreadsFromConsecutiveAnchors) {
FrameDecoder dec;
dec.setSignals({burst("Sine", kTimeFirstSample, 0.0, 4, 1),
timeSignal("Time", 1)});
/* 4 samples per packet, anchors 4 ms apart: a 1 ms period. Arrivals are
* jittered so a spread accidentally taken from arrival would be visible. */
const double jitter[4] = {0.0, 0.0021, -0.0017, 0.0};
std::vector<double> ts;
for (int p = 0; p < 5; p++) {
FrameBuilder fb;
fb.addSignal({1.0, 2.0, 3.0, 4.0});
fb.addSignal({7.0e9 + p * 4.0e6}); /* ns, +4 ms per packet */
const FrameView& f = fb.build(0, 2000.0 + p * 0.004 + jitter[p % 4], 4,
static_cast<uint32_t>(p + 1));
dec.beginFrame(f);
ASSERT_TRUE(dec.timestamps(f, 0, ts));
ASSERT_EQ(ts.size(), 4u);
for (size_t i = 1; i < ts.size(); i++) {
/* The first packet has no predecessor to measure against and legally
* stacks; from the second on the array must be spread. */
if (p > 0) { ASSERT_GT(ts[i], ts[i - 1]) << "packet " << p; }
}
if (p > 0) { EXPECT_NEAR(ts[1] - ts[0], 0.001, 1e-9) << "packet " << p; }
}
/* A lost datagram doubles the anchor difference; without reading the counter
* the recovery packet would be spread twice as wide. */
FrameBuilder fb;
fb.addSignal({1.0, 2.0, 3.0, 4.0});
fb.addSignal({7.0e9 + 5 * 4.0e6 + 4.0e6}); /* packet 6 arrives, 5 lost */
const FrameView& f = fb.build(0, 2000.024, 4, 7u);
dec.beginFrame(f);
ASSERT_TRUE(dec.timestamps(f, 0, ts));
EXPECT_NEAR(ts[1] - ts[0], 0.001, 1e-9) << "loss stretched the array";
}
TEST(FrameDecoder, LastSampleAnchorsTheFinalElementAndCountsBackward) {
FrameDecoder dec;
dec.setSignals({burst("Sine", kTimeLastSample, 1000.0, 4, 1),
@@ -795,10 +838,13 @@ TEST(FrameDecoder, UndeclaredAccumulatedScalarEndsItsBurstOnArrival) {
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. */
/* 10 ms per packet of 10 samples. The cadence used to matter — the first
* hrt-branch packet had no measurable interval, latched ClockOffset using
* kDefaultDt, and only a 1 ms derived period made that harmless — but the
* warm-up now hands over a real tick reference, so this assertion holds at
* every cadence. See UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly,
* which is the test that pins that down; this one only fixes the convention
* that a burst ends, rather than starts, on arrival. */
double lastArrival = 0.0;
std::vector<double> last;
for (int p = 0; p < 60; p++) {
@@ -822,10 +868,14 @@ TEST(FrameDecoder, UndeclaredAccumulatedScalarEndsItsBurstOnArrival) {
// packetBurst spans arrival gaps until HrtRateFit has collected enough packets,
// then the hrt branch takes over. They place a burst differently — packetBurst
// ends it at wallNow, the hrt branch at wallNow - (nElems-1)*hrtDt — so the
// handover is where a discontinuity hides. It is invisible at 10 samples per
// 10 ms packet, the one cadence where the derived period equals the kDefaultDt
// fallback, which is exactly why the other tests here could not see it. Sweep
// cadences either side of that coincidence.
// handover is where a discontinuity hides, and it took two separate blind spots
// for the other tests to miss it. UndeclaredAccumulatedScalarEndsItsBurstOnArrival
// runs at 10 samples per 10 ms, the one cadence where the derived period equals
// the kDefaultDt fallback, so nothing was wrong to see. The two long-run tests
// run at 10 samples per 25 ms, where the fallback burst is 9 ms against a 25 ms
// packet interval — too narrow to invert, so their monotonicity assertions held
// while the trace sat 13.5 ms off the wall clock, which neither of them measures.
// So sweep cadences either side of the coincidence AND assert absolute position.
TEST(FrameDecoder, UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly) {
struct Case { uint32_t nElems; double packetSec; };
const Case cases[] = {
@@ -883,6 +933,146 @@ TEST(FrameDecoder, UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly) {
}
}
// Lost datagrams widen the hrt tick gap without widening the sample count that
// gap is divided by, so a recovery burst is drawn as many times too wide as the
// counter gap — and because a burst is anchored on its LAST element, too wide
// means it ends in the FUTURE. The declared branch reads the counter to
// reinstate the hole exactly; this pins the hrt branch to the same standard.
// Assert POSITION, not just spacing: a burst can be correctly spaced and still
// be drawn across the wrong stretch of the axis.
TEST(FrameDecoder, UndeclaredAccumulatedScalarKeepsItsSpacingThroughPacketLoss) {
FrameDecoder dec;
dec.setSignals({undeclaredAcc()});
const double ticks = 1.0e9;
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
const double packetSec = 0.025;
const double sampleDt = 0.0025;
/* Runs of 1, 4 and 10 consecutive losses, well clear of each other and of
* the fit warm-up. Ten losses is the interesting one: it used to stretch the
* recovery burst 11x and date its last sample 225 ms into the future. */
const int dropFrom[3] = {120, 200, 300};
const int dropLen[3] = {1, 4, 10};
double worstFuture = 0.0;
double last = 0.0;
bool seen = false;
for (int p = 0; p < 500; p++) {
bool dropped = false;
for (int k = 0; k < 3; k++) {
if (p >= dropFrom[k] && p < dropFrom[k] + dropLen[k]) { dropped = true; }
}
if (dropped) { continue; }
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
const uint64_t hrt = bootHrt + static_cast<uint64_t>(p * packetSec * ticks);
const double arrival = 700.0 + p * packetSec;
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)) { continue; }
for (double t : ts) {
if (seen) { ASSERT_GT(t, last) << "backwards at packet " << p; }
last = t;
seen = true;
}
if (p > 100) {
/* The samples were acquired BEFORE the packet carrying them landed,
* so none of them may be stamped after its arrival. */
const double future = ts.back() - arrival;
if (future > worstFuture) { worstFuture = future; }
EXPECT_NEAR(ts[1] - ts[0], sampleDt, sampleDt * 1e-3)
<< "spacing stretched at packet " << p;
}
}
EXPECT_LT(worstFuture, 1e-6)
<< "a recovery burst ended " << worstFuture << " s in the future";
}
// A restart is the other way kDefaultDt gets latched: hrt goes backwards, so the
// restart packet measures no interval of its own, and whatever burst width it
// falls back on is baked into ClockOffset. The displacement that leaves — 13.5 ms
// at this cadence — is below ClockOffset::kRecalibThresholdS, so it never heals.
// AccumulatedScalarSurvivesAProducerRestart asserts only order and spacing and
// passes right through it; this asserts absolute position.
TEST(FrameDecoder, UndeclaredAccumulatedScalarReturnsToTheWallClockAfterARestart) {
FrameDecoder dec;
dec.setSignals({undeclaredAcc()});
const double ticks = 1.0e9;
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
const double packetSec = 0.025;
std::vector<double> lastTs;
double lastArrival = 0.0;
for (int p = 0; p < 400; p++) {
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
/* Packet 200 restarts the producer: hrt returns to a fresh boot and the
* counter to 1. The wall clock does not restart. */
const bool after = (p >= 200);
const uint64_t hrt = after
? static_cast<uint64_t>((p - 200) * packetSec * ticks)
: bootHrt + static_cast<uint64_t>(p * packetSec * ticks);
const uint32_t counter = after ? static_cast<uint32_t>(p - 199)
: static_cast<uint32_t>(p + 1);
const double arrival = 700.0 + p * packetSec;
const FrameView& f = fb.build(hrt, arrival, 10, counter);
dec.beginFrame(f);
std::vector<double> ts;
if (dec.timestamps(f, 0, ts)) { lastTs = ts; lastArrival = arrival; }
}
ASSERT_EQ(lastTs.size(), 10u);
EXPECT_NEAR(lastTs.back(), lastArrival, 1e-6)
<< "still displaced from the wall clock 200 packets after the restart";
EXPECT_NEAR(lastTs[1] - lastTs[0], 0.0025, 2.5e-6);
}
// hrt == 0 sends the packet back to the warm-up branch, which spans from
// packetBurst's own lastPacketWall. The hrt branch does not otherwise touch that
// field, so it would be left at whenever this signal last took the warm-up
// branch — the start of the session — and one stray packet would emit a burst
// starting seconds in the past, worse the longer the scope has been running.
TEST(FrameDecoder, UndeclaredAccumulatedScalarSurvivesAStrayZeroHrtPacket) {
FrameDecoder dec;
dec.setSignals({undeclaredAcc()});
const double ticks = 1.0e9;
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
const double packetSec = 0.025;
double last = 0.0;
bool seen = false;
for (int p = 0; p < 200; p++) {
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
const uint64_t hrt = (p == 153)
? 0u
: bootHrt + static_cast<uint64_t>(p * packetSec * ticks);
const double arrival = 700.0 + p * packetSec;
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)) { continue; }
for (double t : ts) {
if (seen) {
ASSERT_GT(t, last) << "stray zero-hrt packet stepped back "
<< (last - t) << " s at packet " << p;
}
last = t;
seen = true;
}
/* And it must not land far from where the stream already is: spanning
* from a session-old reference put the burst 3.8 s in the past. */
if (p > 100) { EXPECT_NEAR(ts.back(), arrival, 0.05) << "at packet " << p; }
}
}
// 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