From 922e9d5652ede50b867db5640fe838a04f393b9c Mon Sep 17 00:00:00 2001 From: on8st <258096273+on8st@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:50:26 +0200 Subject: [PATCH 1/4] fix(hl2): rate-limit the repeated ADC-overload warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overload bit is a per-frame sample of a level comparator, not an event. On a strong band it dithers, so the edge gate in publishTelemetry sees an edge nearly every time it looks and the same warning repeats at the full telemetry cadence for as long as the band stays strong. On the severity, stated carefully. docs/HERMES.md 15.7 recorded ~133 warnings/second on the MW broadcast band, flushing the log ring and burying the lines a diagnosis needed. That figure is stale: MetisClient has since coalesced telemetryUpdated to 10 Hz (#4449), which caps this at ~10/s however hard the comparator chatters. This change is not offered as a fix for ring-flushing, which the coalescer already ended. It is offered for what is left — one message repeating ten times a second, indefinitely, which still buries its neighbours over a session. The edge gate is necessary and was never sufficient, so it stays and a rate limit sits behind it: warn on the first edge, then at most once per 10 s window, carrying the count of transitions the window swallowed. The count is reported rather than dropped because the rate is the severity here. It counts transitions SEEN, at the 10 Hz telemetry cadence, not comparator edges — those are sampled far below their true rate, and were before this change too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CkisdWQiVxcUG6r6X9W3Fg --- src/core/backends/hl2/Hl2Backend.cpp | 32 ++++++++++++++++++++++++++++ src/core/backends/hl2/Hl2Backend.h | 21 ++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/core/backends/hl2/Hl2Backend.cpp b/src/core/backends/hl2/Hl2Backend.cpp index cdda9284c..429b52aa7 100644 --- a/src/core/backends/hl2/Hl2Backend.cpp +++ b/src/core/backends/hl2/Hl2Backend.cpp @@ -4869,7 +4869,39 @@ void Hl2Backend::publishTelemetry(const Hl2Telemetry& t) if (t.adcOverload && *t.adcOverload != m_adcOverload) { m_adcOverload = *t.adcOverload; if (m_adcOverload) + ++m_adcOverloadEdges; + } + // Rate-limited, not merely edge-gated. The edge gate above is necessary and + // was never sufficient: the comparator genuinely chatters on a strong band, + // so nearly every telemetry sample is an edge and one message repeats at the + // full telemetry cadence (see the members' comment in the header for the + // rate, and for why the historical figure there is not repeated as a + // current one). + // + // Deliberately OUTSIDE the edge test, and this is the whole reason the two + // are separate: a burst that stops must still report its tally. Flushing + // only on the next edge would hold the count until the band goes loud + // again, which could be hours away or never. publishTelemetry runs on every + // telemetry update, so the window closes on time whether or not the + // condition is still happening. + // + // Reported rather than dropped because the rate IS the severity here — a + // flag that sets once is a hint, one that sets on every sample for a minute + // is a front end being slammed. + if (m_adcOverloadEdges > 0 + && (!m_adcOverloadClock.isValid() + || m_adcOverloadClock.hasExpired(kAdcOverloadWarnIntervalMs))) { + // More than one transition means the clock is valid: the first ever + // transition always reports immediately, so anything counted beyond it + // was counted against a running window. + if (m_adcOverloadEdges > 1) + qWarning() << "Hl2Backend: ADC OVERLOAD — reduce LNA gain or attenuate" + << "(" << m_adcOverloadEdges << "times in" + << m_adcOverloadClock.elapsed() << "ms)"; + else qWarning() << "Hl2Backend: ADC OVERLOAD — reduce LNA gain or attenuate"; + m_adcOverloadClock.restart(); + m_adcOverloadEdges = 0; } } diff --git a/src/core/backends/hl2/Hl2Backend.h b/src/core/backends/hl2/Hl2Backend.h index e1446009c..6e398e9a9 100644 --- a/src/core/backends/hl2/Hl2Backend.h +++ b/src/core/backends/hl2/Hl2Backend.h @@ -645,6 +645,27 @@ class Hl2Backend : public IRadioBackend { quint64 m_linkRxPacketsAtLastTick = 0; static constexpr int kLinkStatsIntervalMs = 1000; bool m_adcOverload = false; + // The overload bit is a per-frame sample of a level comparator, not an + // event: on a strong band it dithers, so the edge gate in publishTelemetry + // sees an edge nearly every time it looks. docs/HERMES.md 15.7 recorded + // ~133 warnings/second on the MW broadcast band, flushing the log ring. + // + // That figure is stale and deliberately not repeated as a present-tense + // claim: MetisClient has since coalesced telemetryUpdated to 10 Hz (#4449), + // which caps this at ~10/s however hard the comparator chatters. What + // remains is one message repeating ten times a second for as long as the + // band stays strong — no longer ring-flushing, still enough to bury the + // lines around it over a session. + // + // So the edge gate stays and a rate limit sits behind it: warn on the first + // transition, then at most once per window, carrying the count of + // transitions the window swallowed. Note what that count is and is not — it + // counts the transitions SEEN, at the 10 Hz telemetry cadence, not + // comparator edges, which are sampled far below their true rate and always + // were. + QElapsedTimer m_adcOverloadClock; + int m_adcOverloadEdges = 0; + static constexpr qint64 kAdcOverloadWarnIntervalMs = 10000; bool m_keyed = false; bool m_tuning = false; bool m_cwAutoKeyed = false; From 8e20af6265c6f5734b24b1071354692355d823e3 Mon Sep 17 00:00:00 2001 From: on8st <258096273+on8st@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:05:35 +0200 Subject: [PATCH 2/4] docs(hermes): mark 6a done and correct 15.7's stale severity Review nit: the header comment goes out of its way to call the ~133/s figure stale, and the doc it cites still asserted it in the present tense. Row 6a is struck with the convention the table already uses for 12a, and 15.7 now records what actually happened. 15.7 is corrected rather than rewritten. The rate was real when measured; it stopped being reachable at #4449, when telemetryUpdated was coalesced to 10 Hz -- which capped this at ~10/s and ended the ring-flushing without anyone recording that it had. A symptom that quietly stops reproducing is worth more as a corrected entry than a deleted one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CkisdWQiVxcUG6r6X9W3Fg --- docs/HERMES.md | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/HERMES.md b/docs/HERMES.md index be4a85ab7..bc1965246 100644 --- a/docs/HERMES.md +++ b/docs/HERMES.md @@ -927,7 +927,7 @@ Effort is rough: **XS** under an hour, **S** a session, **M** a few sessions, | 4 | Pipeline reset `0x39[7:4]=0x8` after an NCO move | A2 §B2 | Decimation state smears a transient across band-scale jumps — which `a1cbe154` made routine | XS | | 5 | Normalize by `2^23-1`, not `2^23` | A1 §A2 | dBFS parity with piHPSDR. Numerically trivial, but parity is the point | XS | | 6 | `RXASetNC` / `RXASetMP` after `OpenChannel` | A3 §7 | Selectivity vs latency; matters to CW operators. We silently take defaults | XS | -| 6a | Rate-limit the ADC-overload warning | §15.7 | Edge-gated, but the value chatters: **~133 warnings/second** on MW, which flushes the log ring and hides everything else | XS | +| ~~6a~~ | ~~Rate-limit the ADC-overload warning~~ **DONE** | §15.7 | The edge gate stays and a 10 s rate limit sits behind it, carrying the count of transitions the window swallowed. Note the severity here was already overstated when this row was written — see §15.7 | — | ### Tier 2 — correctness gaps @@ -1751,11 +1751,26 @@ value the assertion depends on, even when it looks like a constant. ### 15.7 Noticed, not fixed -- **ADC overload chatter.** On the MW broadcast band with the default +20 dB LNA - the overload flag dithers, and the warning in `publishTelemetry` — although - edge-gated — fires **~133 times/second**, flushing the log ring. The gate is on - the value changing, but the value genuinely chatters. It also buries every - other log line, which is how it obstructed the diagnosis in §15.5. +- **ADC overload chatter — fixed, and the figure below was already stale.** On + the MW broadcast band with the default +20 dB LNA the overload flag dithers, + and the warning in `publishTelemetry` — although edge-gated — was measured at + **~133 times/second**, flushing the log ring and burying every other line, + which is how it obstructed the diagnosis in §15.5. The gate is on the value + changing; the value genuinely chatters. + + **That rate has not been reachable since #4449.** `MetisClient` coalesces + `telemetryUpdated` to 10 Hz (`kTelemetryMinIntervalMs`), with no + change-bypass, so `publishTelemetry` cannot run faster than 10 Hz however hard + the comparator chatters — which capped this at ~10/s and ended the + ring-flushing without anyone recording that it had. **Kept rather than + rewritten**, because a symptom that stops being reproducible for a reason + nobody wrote down is worth more as a corrected entry than as a deleted one. + + The remainder — one message repeating up to ten times a second for as long as + the band stays strong — is fixed: the edge gate stays and a 10 s rate limit + sits behind it, reporting the count of transitions the window swallowed. + That count is transitions *seen*, at the 10 Hz telemetry cadence, not + comparator edges, which are sampled far below their true rate and always were. - **The HL2 LNA gain is only settable at connect time** (`lnaGainDb` param). There is no seam verb for RF gain, so an operator on a strong band cannot back it off without reconnecting. This is why the overload above could not simply be From 776a70d17514bcb7d9b1f059185b4dc6e3691603 Mon Sep 17 00:00:00 2001 From: on8st <258096273+on8st@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:05:35 +0200 Subject: [PATCH 3/4] fix(hl2): correct the flush comment, and stop QDebug spacing the paren Two review nits, neither behavioural. The comment claimed more than the code does. It said a single counted transition is the first-ever one and therefore reports immediately. It is not: it means exactly one transition was seen in this window, and that one may have arrived at any point since the window opened, so a bare message can lag the event by up to the window. The comment now says that, says it is accepted and why, and separately states the reason reading elapsed() is safe -- which was the part that was actually load bearing and was buried. And "(" streamed as its own item makes QDebug insert a space after it, printing "( 51 times in 10000 ms)". Composed into one string with noquote instead. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CkisdWQiVxcUG6r6X9W3Fg --- src/core/backends/hl2/Hl2Backend.cpp | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/core/backends/hl2/Hl2Backend.cpp b/src/core/backends/hl2/Hl2Backend.cpp index 429b52aa7..24140fdd2 100644 --- a/src/core/backends/hl2/Hl2Backend.cpp +++ b/src/core/backends/hl2/Hl2Backend.cpp @@ -4891,13 +4891,29 @@ void Hl2Backend::publishTelemetry(const Hl2Telemetry& t) if (m_adcOverloadEdges > 0 && (!m_adcOverloadClock.isValid() || m_adcOverloadClock.hasExpired(kAdcOverloadWarnIntervalMs))) { - // More than one transition means the clock is valid: the first ever - // transition always reports immediately, so anything counted beyond it - // was counted against a running window. + // Why reading elapsed() here is safe: more than one transition implies + // the clock is valid. This flush is unconditional on every telemetry + // update and the counter rises by at most one per update, so the first + // transition after an invalid clock always flushes in the same call and + // resets the count. The count can only exceed one against a running + // window. + // + // What the single-transition branch does NOT mean. It is not "this is + // the first overload ever" — it is "exactly one transition was seen in + // this window". That lone transition may have arrived at any point since + // the window opened, so a bare message can lag the event by up to + // kAdcOverloadWarnIntervalMs. Accepted deliberately: it is the cost of + // the rate limit, one transition is a hint rather than an emergency, and + // an isolated overload after a quiet period still reports immediately + // because the clock is long expired by then. if (m_adcOverloadEdges > 1) - qWarning() << "Hl2Backend: ADC OVERLOAD — reduce LNA gain or attenuate" - << "(" << m_adcOverloadEdges << "times in" - << m_adcOverloadClock.elapsed() << "ms)"; + // noquote + one composed string: streaming "(" as its own item makes + // QDebug insert a space after it and print "( 51 times in 10000 ms)". + qWarning().noquote() + << "Hl2Backend: ADC OVERLOAD — reduce LNA gain or attenuate" + << QStringLiteral("(%1 times in %2 ms)") + .arg(m_adcOverloadEdges) + .arg(m_adcOverloadClock.elapsed()); else qWarning() << "Hl2Backend: ADC OVERLOAD — reduce LNA gain or attenuate"; m_adcOverloadClock.restart(); From e3d28da04abfbaa120a340af8f4abad7119c2f28 Mon Sep 17 00:00:00 2001 From: on8st <258096273+on8st@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:24:56 +0200 Subject: [PATCH 4/4] fix(hl2): start() not restart() on the overload clock, behind a policy seam Two blockers from Ozy311's #5381 review. restart() reads the elapsed time before restarting, and reading elapsed time from a QElapsedTimer that was never started is undefined. The first-assertion path guarantees the clock is invalid -- it is the path where the limiter arms for the very first time -- so the arming that mattered most was the undefined one. start() is defined on a fresh and a running timer alike, and the value restart() returned was discarded. The decision now lives in Hl2OverloadPolicy.h, evaluated by the backend rather than copied into it. That is the point of the seam: this behaviour was previously reachable only by running a radio into a strong band for ten seconds, which is exactly why it shipped carrying a bug no compiler could see. Thirteen assertions cover the four cases the review named -- the first assertion reporting immediately against an invalid clock, chatter suppressed inside the window, the window flushing without a new assertion so a stopped burst still reports its tally, and an isolated overload after a quiet interval -- plus three it did not: silence when nothing was seen, on both an expired window and an invalid clock, without which a quiet radio would warn forever; and both sides of the expiry boundary. Mutation-checked rather than asserted. Four mutations, each caught by three or four assertions: the first-assertion special case removed, the aggregate threshold off by one, the expiry boundary made strict, and the nothing-seen guard deleted. m_adcOverloadEdges becomes m_adcOverloadAssertions and the comments that said "transitions" now say "assertions" -- the counter only ever increments on a rising edge, so "transitions" overstated what it holds and invited exactly the off-by-one the mutation above checks for. Checkers: check_engine_boundary.py --strict 0 blocking (86 pre-existing tracked warnings), check_test_registration.py --strict OK, git diff --check clean, backend object and test both build, 13/13 pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CkisdWQiVxcUG6r6X9W3Fg --- src/core/backends/hl2/Hl2Backend.cpp | 47 +++++---- src/core/backends/hl2/Hl2Backend.h | 6 +- src/core/backends/hl2/Hl2OverloadPolicy.h | 65 ++++++++++++ tests/hl2_overload_policy_test.cpp | 116 ++++++++++++++++++++++ tests/tests.cmake | 5 + 5 files changed, 215 insertions(+), 24 deletions(-) create mode 100644 src/core/backends/hl2/Hl2OverloadPolicy.h create mode 100644 tests/hl2_overload_policy_test.cpp diff --git a/src/core/backends/hl2/Hl2Backend.cpp b/src/core/backends/hl2/Hl2Backend.cpp index 24140fdd2..d316b4aa7 100644 --- a/src/core/backends/hl2/Hl2Backend.cpp +++ b/src/core/backends/hl2/Hl2Backend.cpp @@ -8,6 +8,7 @@ #include "core/backends/hl2/Hl2RxDsp.h" #include "core/backends/hl2/Hl2TxDsp.h" +#include "core/backends/hl2/Hl2OverloadPolicy.h" #include "core/backends/hl2/Hl2TxLevelPolicy.h" #include "core/backends/hl2/MetisClient.h" #include "core/backends/hl2/MetisProtocol.h" @@ -4869,7 +4870,7 @@ void Hl2Backend::publishTelemetry(const Hl2Telemetry& t) if (t.adcOverload && *t.adcOverload != m_adcOverload) { m_adcOverload = *t.adcOverload; if (m_adcOverload) - ++m_adcOverloadEdges; + ++m_adcOverloadAssertions; } // Rate-limited, not merely edge-gated. The edge gate above is necessary and // was never sufficient: the comparator genuinely chatters on a strong band, @@ -4888,36 +4889,40 @@ void Hl2Backend::publishTelemetry(const Hl2Telemetry& t) // Reported rather than dropped because the rate IS the severity here — a // flag that sets once is a hint, one that sets on every sample for a minute // is a front end being slammed. - if (m_adcOverloadEdges > 0 - && (!m_adcOverloadClock.isValid() - || m_adcOverloadClock.hasExpired(kAdcOverloadWarnIntervalMs))) { - // Why reading elapsed() here is safe: more than one transition implies - // the clock is valid. This flush is unconditional on every telemetry - // update and the counter rises by at most one per update, so the first - // transition after an invalid clock always flushes in the same call and - // resets the count. The count can only exceed one against a running - // window. - // - // What the single-transition branch does NOT mean. It is not "this is - // the first overload ever" — it is "exactly one transition was seen in - // this window". That lone transition may have arrived at any point since - // the window opened, so a bare message can lag the event by up to + const AetherSDR::hl2::AdcOverloadWarn w = AetherSDR::hl2::adcOverloadWarn( + m_adcOverloadAssertions, + m_adcOverloadClock.isValid(), + m_adcOverloadClock.isValid() ? m_adcOverloadClock.elapsed() : 0, + kAdcOverloadWarnIntervalMs); + if (w.warn) { + // What the aggregate branch does NOT mean. It is not "this is the first + // overload ever" — it is "exactly one assertion was seen in this + // window". That lone assertion may have arrived at any point since the + // window opened, so a bare message can lag the event by up to // kAdcOverloadWarnIntervalMs. Accepted deliberately: it is the cost of - // the rate limit, one transition is a hint rather than an emergency, and + // the rate limit, one assertion is a hint rather than an emergency, and // an isolated overload after a quiet period still reports immediately // because the clock is long expired by then. - if (m_adcOverloadEdges > 1) + if (w.aggregate) { // noquote + one composed string: streaming "(" as its own item makes // QDebug insert a space after it and print "( 51 times in 10000 ms)". qWarning().noquote() << "Hl2Backend: ADC OVERLOAD — reduce LNA gain or attenuate" << QStringLiteral("(%1 times in %2 ms)") - .arg(m_adcOverloadEdges) + .arg(w.count) .arg(m_adcOverloadClock.elapsed()); - else + } else { qWarning() << "Hl2Backend: ADC OVERLOAD — reduce LNA gain or attenuate"; - m_adcOverloadClock.restart(); - m_adcOverloadEdges = 0; + } + if (w.restartClock) { + // start(), NOT restart(). restart() reads the elapsed time first, + // and reading it on a timer that was never started is undefined — + // which is exactly the first-assertion path, where the clock is + // invalid by construction. start() is defined on both, and the + // value restart() returns was discarded anyway. (#5381 review.) + m_adcOverloadClock.start(); + } + m_adcOverloadAssertions = 0; } } diff --git a/src/core/backends/hl2/Hl2Backend.h b/src/core/backends/hl2/Hl2Backend.h index 6e398e9a9..6a6430f69 100644 --- a/src/core/backends/hl2/Hl2Backend.h +++ b/src/core/backends/hl2/Hl2Backend.h @@ -659,12 +659,12 @@ class Hl2Backend : public IRadioBackend { // // So the edge gate stays and a rate limit sits behind it: warn on the first // transition, then at most once per window, carrying the count of - // transitions the window swallowed. Note what that count is and is not — it - // counts the transitions SEEN, at the 10 Hz telemetry cadence, not + // assertions the window swallowed. Note what that count is and is not — it + // counts the assertions SEEN, at the 10 Hz telemetry cadence, not // comparator edges, which are sampled far below their true rate and always // were. QElapsedTimer m_adcOverloadClock; - int m_adcOverloadEdges = 0; + int m_adcOverloadAssertions = 0; static constexpr qint64 kAdcOverloadWarnIntervalMs = 10000; bool m_keyed = false; bool m_tuning = false; diff --git a/src/core/backends/hl2/Hl2OverloadPolicy.h b/src/core/backends/hl2/Hl2OverloadPolicy.h new file mode 100644 index 000000000..9cab60278 --- /dev/null +++ b/src/core/backends/hl2/Hl2OverloadPolicy.h @@ -0,0 +1,65 @@ +#pragma once + +// When the ADC-overload warning may be emitted, as a pure decision. +// +// The AD9866's overload flag is a per-frame sample of a level comparator, not +// an event. On a strong band it chatters, so nearly every telemetry sample is a +// rising edge and one message repeats at the full telemetry cadence. An edge +// gate alone is necessary and never sufficient. +// +// Two properties make this worth a seam rather than an inline condition, and +// both are timing-dependent in a way that is otherwise only reachable by +// running a radio for ten seconds: +// +// * THE FLUSH IS NOT ON AN EDGE. A burst that stops must still report its +// tally. Deciding only when the flag next asserts would hold the count +// until the band goes loud again -- which may be hours away, or never. +// * THE FIRST ASSERTION IS IMMEDIATE. A limiter that made the operator wait +// out a window before the first warning would be silent during exactly the +// interval when the front end is being slammed and nobody knows yet. +// +// Hl2Backend evaluates these functions rather than its own copy, so what the +// suite exercises is what the radio runs -- the reasoning Hl2TxLevelPolicy.h +// states, and the same reason it applies here. + +#include + +namespace AetherSDR::hl2 { + +// What publishTelemetry should do with the overload counter this update. +struct AdcOverloadWarn { + bool warn = false; // emit anything at all? + bool aggregate = false; // the "(N times in M ms)" form rather than a bare line + int count = 0; // assertions being reported; meaningful when warn + bool restartClock = false; +}; + +// `assertions` counts RISING EDGES of the flag seen since the last flush -- not +// telemetry updates, and not the flag's level. `clockValid` is false before the +// first flush has ever run. +inline AdcOverloadWarn adcOverloadWarn(int assertions, + bool clockValid, + std::int64_t elapsedMs, + std::int64_t intervalMs) +{ + AdcOverloadWarn out; + if (assertions <= 0) { + return out; // nothing seen; the window keeps running + } + // An invalid clock is the first assertion ever: report it now rather than + // making the operator wait out a window that has not started. + const bool windowOpen = clockValid && elapsedMs < intervalMs; + if (windowOpen) { + return out; // suppressed; the count keeps accruing + } + out.warn = true; + out.count = assertions; + // ONE assertion is a hint and gets the bare line. More than one is the + // rate, and the rate IS the severity: a flag that sets once is a hint, one + // that sets on every sample for a minute is a front end being slammed. + out.aggregate = assertions > 1; + out.restartClock = true; + return out; +} + +} // namespace AetherSDR::hl2 diff --git a/tests/hl2_overload_policy_test.cpp b/tests/hl2_overload_policy_test.cpp new file mode 100644 index 000000000..850a4fff3 --- /dev/null +++ b/tests/hl2_overload_policy_test.cpp @@ -0,0 +1,116 @@ +// The ADC-overload warning's rate limit, as a deterministic decision. +// +// The behaviour is timing-dependent and was previously only reachable by +// running a radio into a strong band for ten seconds — which is why it shipped +// with a bug the compiler could not see: the flush called restart() on a +// QElapsedTimer that the first-assertion path guarantees is invalid, and +// reading elapsed time from a timer that was never started is undefined. +// (#5381 review.) +// +// Hl2Backend evaluates these same functions rather than its own copy, so what +// passes here is what the radio runs +// (core/backends/hl2/Hl2OverloadPolicy.h). + +#include "core/backends/hl2/Hl2OverloadPolicy.h" + +#include + +using AetherSDR::hl2::adcOverloadWarn; + +namespace { + +int g_failures = 0; + +void check(bool ok, const char* what) +{ + std::printf("%s %s\n", ok ? "[ OK ]" : "[FAIL]", what); + if (!ok) { + ++g_failures; + } +} + +constexpr std::int64_t kInterval = 10000; // kAdcOverloadWarnIntervalMs + +} // namespace + +int main() +{ + // ---- 1. The FIRST assertion reports immediately ------------------------ + // + // The clock is invalid before the first flush has ever run. A limiter that + // made the operator wait out a window here would be silent during exactly + // the interval when the front end is being slammed and nobody knows yet. + { + const auto w = adcOverloadWarn(/*assertions=*/1, /*clockValid=*/false, + /*elapsedMs=*/0, kInterval); + check(w.warn, "the first assertion warns immediately, invalid clock"); + check(!w.aggregate, "and takes the bare form, not the count form"); + check(w.restartClock, "and starts the window"); + } + + // ---- 2. Chatter inside the window is SUPPRESSED, and accrues ----------- + { + const auto w = adcOverloadWarn(/*assertions=*/40, /*clockValid=*/true, + /*elapsedMs=*/2500, kInterval); + check(!w.warn, "chatter inside the window emits nothing"); + check(!w.restartClock, "and does not restart the window"); + } + + // ---- 3. …then AGGREGATES when the window expires ----------------------- + { + const auto w = adcOverloadWarn(/*assertions=*/133, /*clockValid=*/true, + /*elapsedMs=*/kInterval, kInterval); + check(w.warn && w.aggregate, "the expired window reports the aggregate"); + check(w.count == 133, "carrying the full count the window swallowed"); + } + + // ---- 4. The window flushes WITHOUT a new assertion --------------------- + // + // The decision is made on every telemetry update, not on an edge. A burst + // that stops must still report its tally: deciding only when the flag next + // asserts would hold the count until the band goes loud again, which may be + // hours away or never. Same inputs as case 3 — the point is that reaching + // this decision requires no new assertion, only the update that carries it. + { + const auto w = adcOverloadWarn(/*assertions=*/7, /*clockValid=*/true, + /*elapsedMs=*/kInterval + 5000, kInterval); + check(w.warn && w.count == 7, + "a stopped burst still reports its tally once the window expires"); + } + + // ---- 5. An isolated assertion after a quiet interval is immediate ------ + { + const auto w = adcOverloadWarn(/*assertions=*/1, /*clockValid=*/true, + /*elapsedMs=*/600000, kInterval); + check(w.warn && !w.aggregate, + "an isolated overload after a long quiet reports at once, bare"); + } + + // ---- 6. Nothing seen means nothing said, however long the window ------- + // + // Guards the flush being unconditional on every telemetry update: without + // this the limiter would warn on a quiet radio forever. + { + const auto a = adcOverloadWarn(0, true, kInterval * 100, kInterval); + const auto b = adcOverloadWarn(0, false, 0, kInterval); + check(!a.warn && !a.restartClock, + "no assertions, expired window: silence"); + check(!b.warn && !b.restartClock, + "no assertions, invalid clock: silence"); + } + + // ---- 7. The boundary is expiry, not strictly-greater -------------------- + { + const auto below = adcOverloadWarn(5, true, kInterval - 1, kInterval); + const auto at = adcOverloadWarn(5, true, kInterval, kInterval); + check(!below.warn, "one ms before expiry is still suppressed"); + check(at.warn, "at expiry it reports — hasExpired() is inclusive"); + } + + if (g_failures == 0) { + std::printf("\nALL PASS\n"); + return 0; + } + std::printf("\nFAILURES PRESENT\n"); + return 1; +} diff --git a/tests/tests.cmake b/tests/tests.cmake index 4f743661f..5d308991d 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -3895,6 +3895,11 @@ add_executable(host_voice_chain_policy_test ) target_include_directories(host_voice_chain_policy_test PRIVATE src) add_test(NAME host_voice_chain_policy_test COMMAND host_voice_chain_policy_test) +add_executable(hl2_overload_policy_test + tests/hl2_overload_policy_test.cpp +) +target_include_directories(hl2_overload_policy_test PRIVATE src) +add_test(NAME hl2_overload_policy_test COMMAND hl2_overload_policy_test) add_executable(hl2_tx_level_policy_test tests/hl2_tx_level_policy_test.cpp )