Skip to content

fix(hl2): log discovery bind failures and silent sweeps. Principle VIII. - #6293

Merged
jensenpat merged 4 commits into
aethersdr:mainfrom
on8st:hl2/discovery-logging
Oct 10, 2026
Merged

jensenpat merged 4 commits into
aethersdr:mainfrom
on8st:hl2/discovery-logging

Conversation

@on8st

@on8st on8st commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Refs #6285. This covers item 3 only (Hermes-Lite 2 discovery). Items 1, 2 and 4 are not touched.

Why

Hl2Discovery.cpp has no log statements. When bind() fails in refreshSockets(), the socket is dropped without a word. So a host where every interface fails to bind ends up with no sockets, and in a support bundle that looks the same as "radio switched off".

What it logs

Four lines on lcDiscovery (aether.discovery): three qCWarning, and the no-reply line at qCInfo, because it fires once per session on every install without an HL2. refreshSockets() runs on every sweep (default interval 5000 ms), so each line is logged when its condition starts, and again only after the condition has cleared and come back:

line when
HL2 discovery: cannot bind <interface> <address>: <error> a bind fails. Logged once per (interface, address), and again only after that pair has bound
HL2 discovery: no usable IPv4 interface; no probe can be sent no socket is left after a refresh
HL2 discovery: cannot send a probe from <address>: <error> neither the directed nor the global broadcast left that socket (writeDatagram() failed). Logged once per local address, and again only after a probe has left it
HL2 discovery: no Hermes-Lite 2 answered 3 sweeps on K interfaces (interface when K is 1) DiscoveryNotices::kSilentSweepsBeforeWarning (3) sweeps in a row get no HL2 reply, i.e. 15 s at the default interval. Any HL2 reply resets the count

A sweep with no socket to send on is not counted as silent, because the no-interface line already covers it. Rejected datagrams (non-HL2 or unparseable) are still not logged, since that would be noisy on a busy subnet.

The default aether.*.debug=false rule hides neither Warning nor Info (lcDiscovery is declared QtDebugMsg), so no LogManager change is needed.

Out of scope: no gateware version floor. As the triage noted, a log line for "unexpected gateware" first needs a minimum version, and that changes behaviour. It belongs in a separate change.

How

  • DiscoveryNotices, a small class in Hl2Discovery.h/.cpp with no sockets, holds the once-per-condition policy. Hl2Discovery feeds it from refreshSockets() (each failed and each successful bind, then the socket and interface counts), from sweepNow() (whether a probe left each socket), from onSweepTimer() (whether the previous sweep got an answer) and from stop() (reset).
  • app_settings_safety_test compiles Hl2Discovery.cpp, so it now also links the shared aether_test_log_manager and aether_test_async_log_writer objects, which provide lcDiscovery.

Tests

New: hl2_discovery_logging_test. It needs no network and no radio. It drives DiscoveryNotices and captures the real aether.discovery category with the product's default aether.*.debug=false rule installed. The checks:

  • a control: a warning on the category is captured, and its debug line is filtered out
  • each of the three lines matches its exact text the first time its condition occurs
  • each is not repeated while the condition persists, and is logged again after it clears and recurs
  • an intermittent reply never reaches the silence threshold
  • reset() (called by stop()) makes all three log again

What the test does not cover: the calls from Hl2Discovery into DiscoveryNotices (refreshSockets, sweepNow, onSweepTimer, onReadyRead). Testing those would need real interfaces and a broadcast on the LAN.

Results on macOS with Qt 6.12.0:

73, ON8ST

🤖 Generated with Claude Code

Hl2Discovery had no log statements. A host where every interface fails
to bind() had an empty socket list and looked exactly like "radio
switched off" in a support bundle (aethersdr#6285, item 3).

Three warnings on lcDiscovery (aether.discovery), each logged when the
condition starts and again only after it clears and recurs, because
refreshSockets() runs on every sweep (default 5000 ms):

- "HL2 discovery: cannot bind <interface> <address>: <error>", once
  per (interface, address) while it keeps failing.
- "HL2 discovery: no usable IPv4 interface; no probe can be sent",
  once while no socket is left after a refresh.
- "HL2 discovery: no Hermes-Lite 2 answered 3 sweeps on K interfaces",
  after kSilentSweepsBeforeWarning = 3 consecutive unanswered sweeps
  (15 s at the default interval); any HL2 reply resets the count.
  Sweeps with no socket are not counted. Rejected datagrams are not
  logged.

The rate-limit policy lives in a small socket-free DiscoveryNotices
class so it is testable without a network:
hl2_discovery_logging_test captures the real category under the
default "aether.*.debug=false" rule. app_settings_safety_test, which
compiles Hl2Discovery.cpp, now links LogManager for lcDiscovery.

No gateware version floor: rejecting old gateware is a behaviour
change and is out of scope here.

Refs aethersdr#6285

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st
on8st requested a review from a team as a code owner October 8, 2026 20:17

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at the PR head eca68d6, against origin/main and the #6285 thread. Thanks — this is unusually disciplined for an observability change: the policy is a socket-free class, the test captures the real category instead of a stub, and the body discloses its own coverage boundary rather than claiming more. Everything below is one framing question plus nits; no blockers.

1. Issue fit — yes, and correctly scoped

#6285 is a four-item issue; this takes item 3 only, and it matches the plan @on8st posted in the thread (2026-10-08 16:48) point for point: one warning per failed bind naming the interface and address, a second line when no socket is left after the refresh, and one rate-limited no-reply line after a run of empty sweeps rather than one per sweep.

The "reply from unexpected gateware" signal in the issue body is correctly absent. The triage comment established it needs a minimum-version floor first, which is a behaviour change, and the author's thread comment agreed. I checked the code supports that reading: Hl2Discovery.cpp:348 rejects only !reply || !reply->isHermesLite2(), and reply->gatewareVersion is accepted unconditionally at :362 with no floor anywhere in the tree. There is no branch to log. Not a gap.

The triage's choice of qCWarning on aether.discovery also checks out: aether.discovery is in LogManager::loadSettings()'s defaultOn set (src/core/LogManager.cpp:372-375), and applyFilterRules() (:207-225) suppresses only Debug globally, so warnings reach a default bundle with no LogManager change. The body's claim is accurate.

One stale premise worth knowing about: the triage comment said tools/docs/test_log_rules.mjs and the docs Log Analyzer "are not in this repo". That was true at 16:34, but #6286 merged at be1290ab2 later the same day. Your branch is based on 3f76f9fe8, two commits behind main, so docs/user/src/components/LogAnalyzer/rules.json genuinely does not exist on your base — see nit 5.

2. Scope

Everything in the diff is explained by the issue. Four files, git diff --stat 3f76f9fe8..HEAD = +325/−1.

File / group What it changes Claimed by title+body Verdict
src/core/backends/hl2/Hl2Discovery.h DiscoveryNotices declaration + 3 members on Hl2Discovery yes In scope
src/core/backends/hl2/Hl2Discovery.cpp the policy, 3 log lines, feed from refreshSockets/onSweepTimer/onReadyRead/stop yes In scope
tests/hl2_discovery_logging_test.cpp new socket-free policy test yes In scope
tests/tests.cmake (2158-2171) aether_test_log_manager + aether_test_async_log_writer on app_settings_safety_test yes, and necessary — that target compiles Hl2Discovery.cpp, which now references lcDiscovery In scope
tests/tests.cmake (2212-2227, 7927) new target + AETHER_SETTINGS_CONSUMERS row yes In scope
  • No CHANGELOG.md entry. Correct — thank you.
  • No sibling target left unfixed. Hl2Discovery.cpp is compiled by exactly two test targets (tests.cmake:2163 and :2217) plus CMakeLists.txt:905; both test targets carry the lcDiscovery objects after this diff, so nothing is left to fail at link. Confirmed empirically too: ci.yml:306 (Linux build) and :1171 (check-macos) build the whole tree with no --target, and both are green — check-windows (:796) builds only AetherSDR plus five named tests, so it does not cover either target.
  • CMake contract met. hl2_discovery_logging_test joins AETHER_SETTINGS_CONSUMERS (tests.cmake:7927); app_settings_safety_test was already there (:8021).
  • No new public surface — no protocol verb, no settings key, no capability field. DiscoveryNotices lands in the existing Hl2Discovery.h, already a touchpoint header, so no new header and no manifest regeneration is owed (Static checks green bears this out). It is a class, not a bool, so no FROZEN_BOOL_COUNT question arises.
  • No - lines beyond one: the only deletion is if (m_sockets.isEmpty()) becoming the m_probeSent assignment plus the same early return. No guard, confirmation or symptom-naming comment was removed.
  • No UI or default-value change, so nothing here reads as smuggled preference (§6 clean), and no a11y surface is touched.
  • Socket tests: none added. I read the test source, not only tests.cmake: hl2_discovery_logging_test.cpp drives DiscoveryNotices directly and contains no bind(), listen(), connectToHost(), peer process or Fake* radio class. QUdpSocket is linked only because Hl2Discovery.cpp is compiled in; the test never instantiates one. Nothing to surface, and no fake peer — right call.

3. Blockers

None.

4. Needs a maintainer ruling (one item)

src/core/backends/hl2/Hl2Discovery.cpp:94-99 — the silent-sweep line is a warning for a condition that is normal for most of the user base.

    qCWarning(lcDiscovery).noquote()
        << QStringLiteral("HL2 discovery: no Hermes-Lite 2 answered %1 sweeps on %2 %3")

m_hl2Discovery.start() at src/gui/MainWindow_Session.cpp:375 is the only call site in the tree — there is no m_hl2Discovery.stop() anywhere, and the start is on no family, setting or ownership gate. So HL2 discovery runs for the whole session for every AetherSDR install, and ~15 s after launch every Flex-only, ANAN-only, Icom-only and RTL-only operator gets:

WRN aether.discovery: HL2 discovery: no Hermes-Lite 2 answered 3 sweeps on N interfaces

It is bounded at one line per session (++m_silentSweeps != kSilentSweepsBeforeWarning can only match once, and m_answered never goes true on those installs), so this is noise, not a flood. But it is a WRN asserting a fault on a majority of installs where nothing is wrong, and it will be in every support bundle on the project from here on.

Two things make this worth a decision rather than a nit:

  • The sibling precedent points the other way. Flex's RadioDiscovery logs its bind failures at warning (src/core/RadioDiscovery.cpp:67, :90 — the latter is what the analyzer's existing discovery-bind-failed rule cites) but logs its "nothing has answered yet" condition at qCDebug (:72). Your bind-failure and no-interface lines match the Flex precedent exactly and qCWarning is right for both — they are abnormal. The no-reply line is the one that diverges.
  • This line cannot carry an analyzer rule at any severity. tools/docs/test_log_rules.mjs check 6 requires that no rule fire on docs/user/log-samples/clean.log; a rule for this string would fire on every clean non-HL2 log. So it is a human-reads-the-bundle line, not an analyzer line. The thread blessed adding it, so that is a legitimate outcome — it is just worth stating, since analyzer visibility is #6285's whole purpose.

If the maintainer wants it quieter, the level is the lever, and the trade is not symmetric:

  • qCInfo — reaches a default bundle unchanged (lcDiscovery is declared QtDebugMsg at LogManager.cpp:17, and applyFilterRules() emits no aether.*.info=false), and passes the test's own filter rule unchanged. Smallest change.
  • qCDebug — matches the Flex sibling and still reaches a default bundle in-product, because aether.discovery is in defaultOn so aether.discovery.debug=true is emitted. But the test installs only aether.*.debug=false and nothing else, so silenceIsLoggedOnceAfterThreshold would go red until that rule is widened.

Either way the two "W|…" expectations in silenceIsLoggedOnceAfterThreshold need their prefix updated. I am not asking for a change — the thread already agreed to this line — just flagging the consequence the thread did not consider. The other two lines should stay qCWarning.

5. Nits (all non-blocking)

  • Hl2Discovery.cpp:74 — the dedup is weaker than the body claims. The body says the bind failure is "logged once per (interface, address) for as long as it keeps failing". m_bindFailures = m_bindFailuresNow; forgets any key absent from the refresh under way — and a key goes absent if the interface is not enumerated, loses IsUp/IsRunning for one refresh, or changes address, not only if it binds. That is the common shape of a bind failure: an adapter whose address is being torn down, or a VPN/virtual NIC that cycles, alternates present-and-failing with absent, and re-logs every other sweep — 10 s apart at the default interval, indefinitely. The comment documents the intent but not this consequence. If you want the stated behaviour, re-arm only on an actual successful bind of that pair (or after N consecutive clean refreshes) rather than on one absence.

  • Hl2Discovery.cpp:80-82 — split the greppable string and an analyzer rule cannot cite it. #6285 asks for "a stable, greppable string, so the analyzer rule can cite its file:line", and test_log_rules.mjs check 2 asserts the cited file literally contains the rule's text. Adjacent-literal concatenation defeats that: only the first fragment is citable. There is no .clang-format in the tree, so one line is fine:

          qCWarning(lcDiscovery).noquote()
              << QStringLiteral("HL2 discovery: no usable IPv4 interface; no probe can be sent");

    Your other two strings are already single literals and need nothing.

  • tests/hl2_discovery_logging_test.cpp:4-7 — "a line this test sees is a line a support bundle gets" is slightly stronger than the truth. AsyncLogWriter::formatLine runs every line through redactPii() (src/core/AsyncLogWriter.cpp:328), whose IPv4 rule (:243) keeps only the last octet, so the bundle actually reads cannot bind en0 *.*.*. 20: …. Harmless — the redaction falls after any plausible rule needle — but the comment should say the address is redacted on the way to disk, since that is what a reader will compare against.

  • No analyzer rule. docs/user/src/components/LogAnalyzer/rules.json on main already carries discovery-bind-failed for the Flex equivalent at RadioDiscovery.cpp:90; the HL2 twin is the obvious companion, and rules/<id>.log + the drift check would then keep the string honest. A rebase onto main is the prerequisite (your base predates #6286). The thread's agreed plan didn't require it, so this is a follow-up, not a condition.

  • Hl2Discovery.cpp:298 vs :306 — two identifiers for the same interface. bindFailed is keyed on iface.humanReadableName() while boundInterfaces counts iface.name(). Cosmetic only (they never meet), but one of the two would read more consistently.

  • Sibling left behind, outside this PR's scope. src/core/backends/anan/AnanDiscovery.cpp:105-109 has the identical silent-bind shape, with a comment that says so out loud — return; // no socket: stay silent rather than half-running — and the file has zero qC* calls. ANAN discovery is started unconditionally right after HL2 in the same function, so an ANAN-G2 owner whose bind fails gets exactly the diagnostic blackout #6285 is about. Not item 3, so not yours to fix here; worth its own issue.

6. Security audit

security-audit (CodeGuard) ran over all five flagged files and refuted all 25 findings. I confirmed the two that mattered:

  • The log lines leak nothing. redactPii() (src/core/AsyncLogWriter.cpp:243, called at :328, and again by SupportBundle.cpp:112) reduces the logged IPv4 to its last octet, and your format puts the address space-delimited and not behind the ver=/version=" lookbehinds that exempt version strings — so it is actually redacted rather than slipping through. The other two values, iface.humanReadableName() and socket->errorString(), are local OS data, not network-controlled, so there is no injection path from a remote party. Answering the audit's own open question: the diff removes no log line, so the bind failure goes from unlogged to WRN rather than from Debug to WRN.
  • The CS-VLN-CMDI-BACKTICK hit at Hl2Discovery.h:33 is a false positive — that line is the comment // A sweep's interval ended; \answered` is true…. Markdown backticks in a C++ comment; the file contains no shell, system()orQProcess`.
  • The static-checks.yml and .claude/skills/tag-release/… findings are on files this PR does not touch — git diff --name-only 3f76f9fe8..HEAD -- .github/ .claude/ returns nothing. Pre-existing, and route separately if at all.

7. What I tried to break (and could not)

  • Hunted for a missed link target, since that is the failure PR CI would hide. Hl2Discovery.cpp is compiled by exactly two test targets and the app; both test targets carry the new objects. Linux build and check-macos compile the whole tree with no --target and are green, so the link is genuinely proven — not inferred from a filtered lane.
  • Attacked the test with mutations. Drop the m_bindFailures.contains(key) early return → "the same failure on later sweeps logs nothing" goes red. Never assign m_bindFailures in endRefresh → "a failure that cleared and came back is logged again" goes red (every refresh would log). != → >= at :92 → "continued silence logs nothing more" goes red. Drop m_noSocketReported = false → "logged again after it recovered and recurred" goes red. Empty any one of the three emitters → its exact-string check goes red. I could not find a check that survives its own guard being broken, and the silenceIsLoggedOnceAfterThreshold static_assert ties the threshold to the asserted strings. The checks are real.
  • Checked the one place the test is weaker than it looks: check(logged(f).isEmpty() && logged(f).isEmpty(), …) short-circuits, so the second refresh never runs when the first already logged. That cannot produce a false pass (the check fails either way), so it is cosmetic, not a hole.
  • Walked the sweep bookkeeping for mis-attribution. onSweepTimer closes the previous window before sweepNow() refreshes, so the m_interfaceCount printed in the warning belongs to the refresh that sent the sweep being closed — correct. m_answered is set for a 0x03 (in-use) reply too, so a radio taken by another client still counts as answering. sweepNow() is public but has no caller outside the class and start(), so there is no out-of-band beginRefresh/endRefresh pair to unbalance. reset() self-assignment is safe.
  • Looked for a reply that gets counted silent. refreshSockets() closes and recreates every socket at the start of each sweep (pre-existing), so a datagram that lands just before the timer fires can be discarded unprocessed and read as a silent sweep. The threshold of 3 absorbs a single such loss, the same slack kMissedSweepsBeforeLost gives the picker, so I am not reporting it — but it is why the threshold should not come down to 1.
  • Verified the body's visibility claim rather than taking it. Read applyFilterRules() and loadSettings(): only Debug is globally suppressed, aether.discovery is default-on, so warnings reach a bundle with no LogManager change. The claim holds. Counted the test's checks: 16, matching the body.
  • Could not check: anything at runtime. I have no build and no network here, so every finding above is reasoned from the code in the head checkout, not reproduced. Specifically unverified: that the three lines actually appear on a host with a failing bind, and the Hl2Discovery → DiscoveryNotices wiring (m_probeSent, m_answered, the if (m_probeSent) gate, the stop() reset). The body discloses that gap honestly and I agree with the reasoning — it needs real interfaces, and a fake peer here would prove nothing. Worth noting the test is the only exerciser of reset(), since stop() has no caller in the tree.

8. Recommendation

Needs maintainer decision — on exactly one thing: whether Hl2Discovery.cpp:94-99 should stay qCWarning now that it is clear the line fires once per session on every install without a Hermes-Lite 2, which is most of them. The thread already blessed the line itself, so this is a severity call, not a redesign, and the Flex sibling at RadioDiscovery.cpp:72 is the precedent to rule against.

Everything else is approvable as it stands: the diff is exactly item 3, the scope is clean, the CMake contract is met, the test is honest about what it does not cover, and the dedup logic survived every mutation I could aim at it. Next step: a ruling on that one line's level (and the matching W| prefixes in silenceIsLoggedOnceAfterThreshold), plus nits 2 and 3 if you want them — the split literal is the one with a downstream consequence, since it is what blocks an analyzer rule from citing the string #6285 asked you to make citable.


🤖 aethersdr-agent · cost: $15.7601 · model: claude-opus-5

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Cisco CodeGuard — static analysis of this PR (25 finding(s))

  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in .claude/skills/tag-release/scripts/check_release_assets.py /tmp/aetherclaude/pr-6293/.claude/skills/tag-release/scripts/check_release_assets.py:67
  • [LOW] CS-PII-IP — PII detected: ip_address in .claude/skills/tag-release/scripts/check_release_assets.py /tmp/aetherclaude/pr-6293/.claude/skills/tag-release/scripts/check_release_assets.py:228
  • [LOW] CS-PII-IP — PII detected: ip_address in .claude/skills/tag-release/scripts/check_release_assets.py /tmp/aetherclaude/pr-6293/.claude/skills/tag-release/scripts/check_release_assets.py:228
  • [MEDIUM] CS-MAL-SCRIPT — Script detected: env python in .claude/skills/tag-release/scripts/check_release_assets.py /tmp/aetherclaude/pr-6293/.claude/skills/tag-release/scripts/check_release_assets.py:0
  • [CRITICAL] CS-VLN-CMDI-SUBSHELL — Vulnerability: CS-VLN-CMDI-SUBSHELL in .github/workflows/static-checks.yml /tmp/aetherclaude/pr-6293/.github/workflows/static-checks.yml:446
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in .github/workflows/static-checks.yml /tmp/aetherclaude/pr-6293/.github/workflows/static-checks.yml:14
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in .github/workflows/static-checks.yml /tmp/aetherclaude/pr-6293/.github/workflows/static-checks.yml:36
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in .github/workflows/static-checks.yml /tmp/aetherclaude/pr-6293/.github/workflows/static-checks.yml:81
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in .github/workflows/static-checks.yml /tmp/aetherclaude/pr-6293/.github/workflows/static-checks.yml:172
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in .github/workflows/static-checks.yml /tmp/aetherclaude/pr-6293/.github/workflows/static-checks.yml:273
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in .github/workflows/static-checks.yml /tmp/aetherclaude/pr-6293/.github/workflows/static-checks.yml:296
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in .github/workflows/static-checks.yml /tmp/aetherclaude/pr-6293/.github/workflows/static-checks.yml:304
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in .github/workflows/static-checks.yml /tmp/aetherclaude/pr-6293/.github/workflows/static-checks.yml:319
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in .github/workflows/static-checks.yml /tmp/aetherclaude/pr-6293/.github/workflows/static-checks.yml:349
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in .github/workflows/static-checks.yml /tmp/aetherclaude/pr-6293/.github/workflows/static-checks.yml:364
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in .github/workflows/static-checks.yml /tmp/aetherclaude/pr-6293/.github/workflows/static-checks.yml:419
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in .github/workflows/static-checks.yml /tmp/aetherclaude/pr-6293/.github/workflows/static-checks.yml:481
  • [MEDIUM] CS-VLN-CMDI-ENVVAR — Vulnerability: CS-VLN-CMDI-ENVVAR in .github/workflows/static-checks.yml /tmp/aetherclaude/pr-6293/.github/workflows/static-checks.yml:484
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/hl2/Hl2Discovery.h /tmp/aetherclaude/pr-6293/src/core/backends/hl2/Hl2Discovery.h:33
  • [HIGH] CS-VLN-SSRF-PRIV-192 — Vulnerability: CS-VLN-SSRF-PRIV-192 in tests/hl2_discovery_logging_test.cpp /tmp/aetherclaude/pr-6293/tests/hl2_discovery_logging_test.cpp:57
  • [HIGH] CS-VLN-SSRF-PRIV-192 — Vulnerability: CS-VLN-SSRF-PRIV-192 in tests/hl2_discovery_logging_test.cpp /tmp/aetherclaude/pr-6293/tests/hl2_discovery_logging_test.cpp:72
  • [LOW] CS-PII-IP — PII detected: ip_address in tests/hl2_discovery_logging_test.cpp /tmp/aetherclaude/pr-6293/tests/hl2_discovery_logging_test.cpp:57
  • [LOW] CS-PII-IP — PII detected: ip_address in tests/hl2_discovery_logging_test.cpp /tmp/aetherclaude/pr-6293/tests/hl2_discovery_logging_test.cpp:58
  • [LOW] CS-PII-IP — PII detected: ip_address in tests/hl2_discovery_logging_test.cpp /tmp/aetherclaude/pr-6293/tests/hl2_discovery_logging_test.cpp:72
  • [LOW] CS-PII-IP — PII detected: ip_address in tests/hl2_discovery_logging_test.cpp /tmp/aetherclaude/pr-6293/tests/hl2_discovery_logging_test.cpp:86

Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them.


🤖 aethersdr-agent · cost: $15.9540 · model: claude-opus-5

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Antares Detector — candidate vulnerable file(s)

  • src/core/backends/hl2/Hl2Discovery.cpp
  • src/core/backends/hl2/Hl2Discovery.h

The HL2 discovery backend uses QNetworkDatagram/QNetworkInterface/QUdpSocket to receive discovery packets and logs bind failures via lcDiscovery.qCWarning. The code path likely constructs or logs QHostAddress values from received packet addresses; if those addresses are not validated/sanitized before being passed to logging or output routines, this can enable path traversal or format-string/denial-of-service issues.

Localized by Cisco Foundation AI Antares-1B running locally in the AetherClaude sandbox, seeded by the Cartographer security map. Advisory only — please verify before acting.


🤖 aethersdr-agent · cost: $15.9540 · model: claude-opus-5

Review of aethersdr#6293:
- The no-reply line fires once per session on every install without an
  Hermes-Lite 2, so it is Info, not a warning. Info still reaches a
  default bundle (aether.discovery is QtDebugMsg; only Debug is
  suppressed globally).
- A logged bind failure re-arms only when that interface and address
  bind, not when it is absent for one refresh, so a cycling adapter no
  longer re-logs every other sweep. beginRefresh() is gone with it.
- The no-interface string is one literal, so a log rule can cite it.
- Bound interfaces are counted by humanReadableName(), as bind failures
  are keyed.
- The test's header says the address is redacted on the way to disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st

on8st commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Fixed in 0e908f4c, a new commit on top (no rebase). Full ctest: 663 of 665 pass; the other two are the clean-main failures #6008 and #6224. The discovery test now has 17 checks. The PR body is updated to match.

  • No-reply line now Info, not a warning. You are right: discovery starts unconditionally (MainWindow_Session.cpp:375), so on an install without an HL2 the line fires once per session and claims a fault that is not there. lcDiscovery is declared QtDebugMsg and applyFilterRules() suppresses only Debug, so the Info line still reaches a default bundle; the test's two expectations now check for Info. The bind-failure and no-interface lines stay warnings. If you would rather have Debug, as in RadioDiscovery.cpp:72, say so and I'll change it along with the test's filter rule.
  • Dedup: a logged bind failure now logs again only after that interface and address have bound once (bindSucceeded()), not when the pair is missing from one refresh. So an adapter that keeps coming and going no longer logs every other sweep. A new check covers the absent-then-back case. beginRefresh() had nothing left to do, so it is removed.
  • The no-interface string is now one literal.
  • Interfaces are counted by humanReadableName(), the same name the bind failures are keyed on.
  • The test header now says the address is redacted to its last octet on the way to disk.
  • Antares: the logged address is the local interface address from QNetworkInterface, not one taken from a received packet, and it goes in through QString::arg, not a format string.

Left for later: the analyzer rule needs main merged into this branch, so it comes as a follow-up. The ANAN silent-bind sibling is outside item 3.

73, ON8ST

ten9876 added a commit that referenced this pull request Oct 9, 2026
…rinciple VIII. (#6308)

Refs #6285 (items 1, 2 and 4). Item 3, Hermes-Lite 2 discovery, is #6293
by @on8st and is not touched here, so #6285 stays open until that lands;
the analyzer rules for #6293's lines follow once its strings are on
`main` (the rule test checks every cited line exists).

## Why

The docs Log Analyzer can only flag a failure the app writes to the log.
Three user-facing failures were never logged, so a support bundle could
not show them either.

## What it logs

| Item | Category, level | Line (stable prefix in bold) | When |
|---|---|---|---|
| 1. VITA-49 sequence errors | `aether.vita49`, Warning |
**`PanadapterStream: VITA-49 sequence errors on`**
`<audio\|FFT\|waterfall\|meter\|DAX> stream 0x<8-digit id>: N in the
last S s, E since the stream started (P packets received)` | The first
error on a stream logs at once; after that, at most one line per stream
per 10 s, carrying every error held back since the last line. A stream
torn down (disconnect; a pan, waterfall, DAX or IQ stream removed; any
stream the radio reports removed, including `remote_audio_rx`) starts
over: its next packet is not compared with the old sequence count, and
`E`, `P` and the window restart; the 10 s limit still applies across the
restart. All streams together are capped at 8 lines per 10 s; a line the
cap refuses keeps its errors for that stream's next line, and the next
line logged is preceded by `PanadapterStream: N VITA-49 sequence
error(s) held back: more than 8 line(s) in 10 s across all streams`. |
| 2. BNR pack | `aether.nvafx`, Warning | **`NvidiaAfxPack: install
failed:`** `<the message the BNR panel shows>` | Every failed install. A
cancel that reaches `fail("cancelled")` (during unpacking) logs
`NvidiaAfxPack: install cancelled` at Info; cancelling an active
transfer logs nothing, because `cancel()` disconnects the reply before
aborting it. |
| 2. BNR pack | `aether.nvafx`, Warning | **`NvidiaAfxPack: transfer
failed:`** `<component>: <Qt error>` or **`NvidiaAfxPack: transfer timed
out (stall limit 30 s):`** `<component>` | A network failure of the
archive download or of a PyPI / simple-index lookup (the lookup's
message in the panel has no reason). |
| 4. Qt platform | `default`, Info | **`Platform: Qt platform plugin`**
`"<name>" (QT_QPA_PLATFORM=<value>, set by the user \| set by
AetherSDR)`, `"<name>" (QT_QPA_PLATFORM unset, Qt default)`, or
`"<name>" (-platform <value>, on the command line)` | Every start-up,
right after the log handler is installed. |
| 4. Qt platform | `default`, Warning | **`Platform: Qt fell back
from`** `"<first>" to "<used>"; the first plugin in <QT_QPA_PLATFORM \|
-platform> did not load` | Only when the plugin in use is not the first
one requested. Qt gives a `-platform` (or `--platform`) argument
precedence over `QT_QPA_PLATFORM`, so when there is one, it is the
request compared. |

### Behaviour changes beside the log lines

- **`-platform` drives AetherSDR's own platform logic.** A
`-platform`/`--platform` argument counts like a user-set
`QT_QPA_PLATFORM`: AetherSDR no longer imposes `wayland;xcb` in a
Wayland session over it, and `GpuSelector`'s GLX/EGL (PRIME) choice
reads the request Qt will actually use
(`GpuSelector::requestPicksWayland`).
- **Sequence tracking restarts with the stream.** `RadioModel` restarts
the sequence tracking of every stream the radio reports removed, in one
place, which covers `remote_audio_rx` (it has no registration in
`PanadapterStream`). Nothing restarts on a status for a stream still
flowing, so a real gap is never hidden. A reused id no longer adds a
false error to the status-bar and Network Diagnostics counts either.
- **`tools/docs/snapshot_stable.py`** rewrites each Log Analyzer rule
link `/next/<page>` to `/<page>` when the new Stable snapshot has that
page.

### Design notes

- **Item 1 uses `qCWarning(lcVita49)`, not `aether.protocol`.** The
triage's correction applies: `aether.vita49` is declared `QtWarningMsg`,
so a Warning reaches a default log with no `LogManager` change, and
packet loss is a warning. The limiter (`VitaSequenceLossReport.h`) lives
in `StreamStats` and runs only on the error path; a shared
`VitaSequenceLossBudget` bounds all streams together, because the
per-stream limiter lets each new id's first error through at once and
the VITA socket does not filter by sender: a clean stream pays one clock
read when it is first seen. Its values are read under `m_statsMutex`;
the line is formatted and logged after the lock is released. Teardown
(`clearRegisteredStreams()` on disconnect,
`unregister{Pan,Wf,Dax,Iq}Stream()`, and `RadioModel` for every stream
the radio reports removed) restarts the stream's sequence tracking,
because a reconnect can reuse a stream id; the cumulative per-stream
totals behind the status bar and Network Diagnostics are kept, so those
counters behave as before, except that a reused id no longer adds a
false error on its first packet. Errors still held when a stream goes
clean are not logged; the live counters (Network Diagnostics) keep them.
A sequence error is any gap in the 4-bit count, so reordered packets
count too (#6051); the docs say "lost or arrive out of order".
- **Item 2.** `lcNvAfx` had no header declaration; it is now declared in
`NvidiaAfxFilter.h`. `aether.nvafx` gets a `LogManager` registry row
("BNR (NVIDIA AFX)") so it can be toggled in the Support dialog; its
Warning lines reach a default log either way, since only Debug is
filtered. The three exits that called `emit finished(false, …)` directly
(no GPU, install verification, offline archive not found) now go through
`fail()`, which only adds the cleanup it already does and the log line.
Redaction: `withoutUrlQueries()` strips URL query strings and fragments
(keeping closing punctuation after them) from logged text, because a
redirected download URL can carry a signed query. `fail()` strips the
message it emits as well as the one it logs, so the BNR panel's
`Failed:` text is redacted too. A failed download logs two lines on
purpose: `transfer failed:` per network transfer (the same line as a
failed lookup, whose panel message has no reason) and `install failed:`
for the outcome. The manifest and PyPI URLs are source constants with no
tokens. `could not install into <path>` is under the home directory,
which the log redactor already collapses to `~`, and `NvidiaAfxFilter`
already logs the pack path.
- **Redaction.** The pre-existing default-level `addr=` (VITA bind) and
`from=` (first packet) lines carry no host keyword, but the IPv4/IPv6
shape rules in `redactPii()` mask them (IPv4 keeps its last octet, IPv6
is replaced, per `docs/log-redaction.md`); three negative-corpus rows in
`async_log_writer_test` pin that. The analyzer page's privacy note now
lists exactly what is masked.
- **Item 4.** A CR or LF in the request is folded to a space, so it
cannot forge a second log line. `main()` reads the `-platform` argument
the way `QGuiApplication` does (one `-` of `--` dropped, the next
argument, the last one winning) before `QApplication` consumes it. The
line lives in a small app-shell helper, `src/QtPlatformChoice.{h,cpp}`,
not in `src/core/`, so `main.cpp` gains no engine include (the
touchpoint manifest stays unchanged). A plugin that fails to load
entirely aborts inside the `QApplication` constructor, before any log
handler exists; that can't be logged in-process, and the docs entry says
so.

## Docs

- Log Analyzer: five rules with positive samples —
`vita-sequence-errors`, `bnr-download-failed`, `bnr-checksum-mismatch`,
`bnr-install-failed`, `qt-platform-fallback`. There is also a **Qt
platform plugin** row in the analyzer's System summary (a `summary`
entry plus `SUMMARY_FIELDS`). The plain platform line is in `clean.log`,
and `test_log_rules.mjs` checks that the summary reads `xcb`.
- Troubleshooting (symptom → fix), Next only, so the new rules link
`/next/…`:
- Low-Bandwidth Connections: *Audio drops out or the waterfall has gaps*
- BNR GPU Noise Removal: *The download fails with "checksum mismatch"*
and *The download finishes but BNR is not installed*. *The download
fails* now mentions the 30 s stall and that downloads resume.
  - GPU Rendering: *AetherSDR runs on XWayland in a Wayland session*
- `bnr-download-failed` links the Stable anchor
`/bnr-gpu-noise-removal#the-download-fails`.
- `snapshot_stable.py` moves `/next/` rule links to Stable at each
snapshot; `docs/user/README.md` and the `/release-prep` snapshot step
describe it. Tested on a scratch copy of the tree: the 4 `/next/` links
became Stable links, `test_log_rules.mjs` passed against the new
snapshot, a second run changed nothing, and a link whose page is not in
the snapshot stays `/next/`.
- `test_log_rules.mjs --update` also refreshed four existing rules whose
cited lines moved in `PanadapterStream.cpp` and `main.cpp`.

## Proof

**Agent automation bridge**: an offscreen build, a fresh settings
profile, and a private network namespace (`unshare -n`, as
`capture_screenshots.py` does), so there is no radio and no internet.

| Step | Before (`888ac58bf`) | After (`18a772b75`) |
|---|---|---|
| Start-up log, `QT_QPA_PLATFORM=offscreen` | no platform line | `INF
default: Platform: Qt platform plugin "offscreen"
(QT_QPA_PLATFORM=offscreen, set by the user)` |
| Wayland session on the desktop, no `QT_QPA_PLATFORM` | — | `INF
default: Platform: Qt platform plugin "wayland"
(QT_QPA_PLATFORM=wayland;xcb, set by AetherSDR)` |
| `QT_QPA_PLATFORM='nosuchplugin;offscreen'` | — | `WRN default:
Platform: Qt fell back from "nosuchplugin" to "offscreen"; the first
plugin in QT_QPA_PLATFORM did not load`; Qt's own `Could not find the Qt
platform plugin "nosuchplugin"` went to stderr only |
| `QT_QPA_PLATFORM=offscreen AetherSDR -platform minimal` (an
intentional override) | — | `INF default: Platform: Qt platform plugin
"minimal" (-platform minimal, on the command line)`, no warning;
`--platform minimal` logs the same |
| Fake Wayland session (`XDG_SESSION_TYPE=wayland`, `WAYLAND_DISPLAY`
set, no `QT_QPA_PLATFORM`) with `-platform offscreen` / `--platform
offscreen` | — | no `Platform: Wayland session … -> QT_QPA_PLATFORM
wayland;xcb` line (AetherSDR no longer imposes it); `INF default:
Platform: Qt platform plugin "offscreen" (-platform offscreen, on the
command line)` |
| `AetherSDR -platform 'nosuchplugin;offscreen'` | — | `INF default:
Platform: Qt platform plugin "offscreen" (-platform
nosuchplugin;offscreen, on the command line)`, then `WRN default:
Platform: Qt fell back from "nosuchplugin" to "offscreen"; the first
plugin in -platform did not load` |
| `invoke "AetherRX..." trigger` → `invoke dspMethodBtnBNR click` →
`invoke "Download BNR runtime" click` → `invoke Accept click` (license
prompt, throwaway profile), then `log mark` / `log tail` | **no line**:
the download fails silently | `W aether.nvafx: NvidiaAfxPack: transfer
failed: AFX runtime: Network unreachable`, then `W aether.nvafx:
NvidiaAfxPack: install failed: download failed (AFX runtime): Network
unreachable` |
| The analyzer engine (`analyze.mjs` + `rules.json`) run on those real
log files | `qrhi-failure`, `bnr-pack-missing`; summary has no platform
| adds `bnr-download-failed` (BNR run) and `qt-platform-fallback`
(fallback run); summary `Qt platform plugin: offscreen` / `wayland` |

(`qrhi-failure` is expected under `offscreen`, as its rule says.)

One thing the bridge showed along the way, not changed here: after the
failed download, `dumpTree` reads the **BNR status** label as `Not
installed`. `updateBnrStatus()` runs straight after the `Failed: <msg>`
text is set and overwrites it, so before this PR the reason for a failed
download appeared nowhere.

**Unit tests** (socket-free, `QT_QPA_PLATFORM=offscreen`, `ctest
--no-tests=error`): all pass.
- `vita_sequence_loss_log_test` (45 checks): the limiter (first error at
once, none for 10 s, held count carried, 7 lines in a minute of
continuous errors, the limit held across a restart) and the exact text.
It also feeds meter packets to `PanadapterStream::processDatagram()` on
a stream that never binds, through a test-access friend like
`PcmCompatibilityTestAccess`, under the product's `aether.*.debug=false`
rule. In-order packets log nothing, one gap logs the exact warning, and
50 more gaps within 10 s add no line. After `clearRegisteredStreams()`,
a reused stream id's first packet logs and counts nothing, the
cumulative total is kept, and the next real gap logs at once with
restarted counts; after each of `unregister{Pan,Wf,Dax,Iq}Stream()` a
re-created stream counts no error.
- `remote_audio_rx_sequence_restart_test` (4): through `RadioModel`'s
status router, a gap across an adoption status still counts, a removed
`remote_audio_rx` id's next packet counts no error, and a later real gap
counts.
- `async_log_writer_test`: negative-corpus rows for the `addr=` /
`from=` VITA lines (IPv4 and IPv6).
- `qt_platform_choice_test` (31): list parsing, `wayland` vs
`wayland-egl`, fallback detection, the exact text for each source
including `-platform`, and reading `-platform` / `--platform` (last
wins, a trailing `-platform` and `-platformtheme` ignored), and
`GpuSelector::requestPicksWayland` on the `-platform` request.
- `nvidia_afx_pack_log_test` (11): the offline import of a missing
archive logs `W aether.nvafx: NvidiaAfxPack: install failed: archive not
found` without changing the `finished()` message; it never calls
`install()`. URL query and fragment stripping, closing punctuation kept,
any scheme case.

**Mutation checks**, each restored and confirmed by sha256 against the
saved original:
- Limiter always reports → 4 checks fail.
- Line downgraded to `qCInfo` → the integration checks fail (Info on
`aether.vita49` is dropped by a default log).
- `fellBack()` always false → 4 fail.
- The missing-archive exit reverted to `emit finished(…)` → the log
check fails.
- `withoutUrlQueries()` returns its input unchanged → 2 fail; the old
greedy tail → the punctuation check fails.
- `start()` resetting the last report again → the 4 restart checks fail.
- `clearRegisteredStreams()` without the sequence restart → 3 fail; each
`unregister*Stream()` without it → its check fails.
- `platformArgument()` never returning a value → 3 fail; without the
`--` handling → 1 fails; `requestPicksWayland` not recognising `xcb` → 2
fail.
- No shared budget → the burst-of-new-ids check fails (20 lines instead
of 8); a refusal that spends the stream's report → 3 fail; a
case-sensitive scheme → 1 fails; no CR/LF folding → 1 fails.
- No restart for a removed stream (`RadioModel` and
`unregisterDaxStream()`) → 2 fail; stream id unpadded → 1 fails;
`fellBack()` prefix one-way → 1 fails.

**Also run**: `pcm_compatibility_test`, `nvidia_afx_filter_test`,
`nr_rate_domain_test` and `flex_backend_lifecycle_test` pass. Every
Static checks script passes locally, including `gen_reference.py
--check`, `test_log_rules.mjs` (707 checks, 45 rules) and
`gen_touchpoint_manifest.py --check`. The colour and a11y steps have no
`src/gui/` files to scan. `cd docs/user && npm ci && npm run build`
passes (links and anchors).

Build: Linux, Qt 6.12.0, lean review configure (`AETHER_SHARED_CORE=ON`,
`-O2 -g1`); targets `AetherSDR` plus the tests above.

### Not verified

- **Item 1 on a real radio.** No live hardware was used for this PR. The
`PanadapterStream` path is exercised by feeding synthetic packets to
`processDatagram()`, not by a lossy network.
- **The stall branch** (`transfer timed out`). I checked with a
standalone Qt 6.12 probe that a transfer timeout reports `TimeoutError`
("Operation timed out"). The app's own stall path was not triggered.
- **The checksum and unpack failures** were not triggered in the app;
their samples are written from the message literals.
- **Windows and macOS**: built and run on Linux only. CI covers the
builds.

### Agent automation bridge — gaps found
- **Replaying a lossy VITA-49 stream.** Driving item 1 end to end needs
packets with sequence gaps on a live stream. The bridge has no verb to
inject or replay VITA-49 datagrams, and the demo backend is not a Flex
`PanadapterStream`. Proposed: a TX-free `sim vita drop <stream>
<every-N>` fault-injection verb (or a pcap replay) on the Flex receive
path.
- **Start-up log lines before the bridge listens.** `log tail` starts at
bridge start-up, so the `Platform:` lines (written earlier in `main()`)
are only in the log file. Proposed: `log file [grep]`, which returns
lines of the current log file.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@Ozy311 Ozy311 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of 0e908f4c, the one commit since the aethersdr-agent review at eca68d65. It takes the open item and the nits:

  • The silent-sweep line is now qCInfo (Hl2Discovery.cpp:90-91). Installs without an HL2, which is most of them, no longer get a WRN in every support bundle. The warning-level lines for a bind failure and for "no usable IPv4 interface" stay qCWarning, which matches the Flex RadioDiscovery precedent. The test captures I| and asserts it.
  • The dedup matches the body now. A failure is forgotten only by bindSucceeded() for that interface-and-address pair, not by being absent from one refresh. A new check pins it: "an interface absent for one refresh does not re-log on return". That closes the VPN or adapter-flap case, which used to re-log every other sweep.
  • The "no usable IPv4 interface" string is one literal again, so an analyzer rule can cite it.
  • The test header says the IPv4 address is redacted to its last octet on its way to the bundle.
  • boundInterfaces is keyed by humanReadableName(), the same identifier bindFailed() and bindSucceeded() use.

I read the bind loop at the head: bindSucceeded() gets the same (humanReadableName(), local) pair that bindFailed() keys on, so a recovered pair is forgotten exactly. The branch merges cleanly with current main. Not run: hl2_discovery_logging_test.

One trivial nit: the silenceIsLoggedOnceAfterThreshold check strings still say "logs one warning", although the line is Info now. Nothing else from me.

73, K6OZY


Generated by Claude Code

Review of aethersdr#6293: the line is Info since the previous commit; the check's
description still said warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st

on8st commented Oct 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the re-review. The check description now says Info: eb69344c, a test-string change only. Full ctest on it: 663 of 665 pass; the two that fail are the clean-main failures #6008 and #6224.

73, ON8ST

sweepNow() discarded writeDatagram()'s result, so a socket that bound but
could not send ("Network unreachable") left the picker empty with nothing
in the log: the path the aethersdr#6299 triage found for ANAN. A warning names the
local address and the error, once per address while nothing leaves it,
and again only after a probe has left that address. One of the directed
and the global broadcast reaching the wire counts as sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st

on8st commented Oct 10, 2026

Copy link
Copy Markdown
Contributor Author

One more commit, 5f11af00, which widens the PR by one line. The triage of #6299 found that ANAN discovery discards the result of writeDatagram(); HL2 discovery did the same in sweepNow(). A socket that binds but cannot send ("Network unreachable") left the picker empty with nothing in the log.

Now: HL2 discovery: cannot send a probe from <address>: <error>, a warning, once per local address, and again only after a probe has left that address. If either the directed or the global broadcast goes out, that counts as sent. Four new checks; the test has 21. Full ctest: 663 of 665, the same two clean-main failures (#6008, #6224). The PR body is updated.

If you would rather keep this PR as reviewed, say so and I will move the commit to a follow-up.

73, ON8ST

@jensenpat jensenpat self-assigned this Oct 10, 2026

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Reviewed 5f11af0048e2eeb7c83dd3b5e0b5dfd4f82d07c2. Approve. This is #6285 item 3 only, and it stays inside Hermes-Lite 2 discovery. It does not touch RadioModel, IRadioBackend, or any other family.

Scope

Path What changed
src/core/backends/hl2/Hl2Discovery.h DiscoveryNotices once-per-condition policy, plus m_probeSent / m_answered
src/core/backends/hl2/Hl2Discovery.cpp Bind, send, empty-interface, and silent-sweep lines on lcDiscovery
tests/hl2_discovery_logging_test.cpp Socket-free checks of that policy (21 assertions)
tests/tests.cmake Registers the new test; app_settings_safety_test links the log objects Hl2Discovery.cpp now needs

No CHANGELOG.md. No new capability, family-string branch, Flex wire text, dynamic_cast, or TX-gate path. Gateware-version logging stays out, matching the issue thread.

The maintainer-decision item from the earlier review (silent-sweep severity) is settled on this head: that line is qCInfo, and the test expects I|. Bind failure, no usable interface, and a probe that never leaves stay qCWarning.

Findings

No blockers. One non-blocking nit is inlined on sweepNow(): a sweep whose sockets exist but whose writeDatagram calls all fail still counts toward the silent-sweep Info line. The send-failure warning already names that condition.

Verification

Required checks on this SHA are green: build, check-macos, check-windows, Static checks, Sanitizer option configures. The per-PR gate does not execute hl2_discovery_logging_test. I built that target from this SHA and ran it headless: 21/21 checks passed.

redactPii() still reduces a space-delimited IPv4 address to its last octet, which matches these lines.

Non-blocking follow-up

The test drives DiscoveryNotices directly. It does not execute Hl2Discovery's calls into that policy (refreshSockets, sweepNow, onSweepTimer, onReadyRead, stop). That wiring is HL2-only. A useful later check is a socket-free driver of those flags if the bookkeeping is split out; a synthetic radio peer would not add anything this change needs.

Comment thread src/core/backends/hl2/Hl2Discovery.cpp
@jensenpat
jensenpat merged commit 8a64156 into aethersdr:main Oct 10, 2026
5 checks passed
rfoust pushed a commit that referenced this pull request Oct 12, 2026
Refs #6293

Answers the non-blocking note left on the approving review of #6293.

`Hl2Discovery::sweepNow` marked a sweep as sent from the socket count,
before the writes. A sweep whose every `writeDatagram` failed therefore
closed as silence, and the third one logged the Info line "HL2
discovery:
no Hermes-Lite 2 answered 3 sweeps…", although `sendFailed` had already
logged the real cause.

From this PR's review (`c818031e`): the silence line now counts the
interfaces a probe actually left in that sweep, not the bound ones, and
the
constant's comment says "at least 15 s". `hl2_discovery_logging_test`
has
29 checks.

## Limits

- Socket-free test only. No radio, no network, no failing socket was
  provoked: the test drives `DiscoveryNotices` with the calls
`Hl2Discovery` makes. That `sweepNow` makes them is read from the code,
  not tested.
- Log policy only. Probing, ageing and the picker signals are unchanged.
- A sweep no probe left neither adds to the silent count nor clears it.
  That is a choice: silence before and after a send outage adds up.
- Built and run on one Mac (macOS, Qt 6.12.0).

## Change

The rule lives in `DiscoveryNotices`, so it is testable without a
socket.
`sendSucceeded` marks the open sweep. `sweepClosed` counts the sweep as
unanswered only when it is marked, and clears the mark. One socket
sending
is enough. `Hl2Discovery` reports every closed sweep and no longer keeps
`m_probeSent`. A sweep with no socket at all is still not counted, by
the
same rule.

## Tests

`hl2_discovery_logging_test`, new function
`sweepWithNoProbeSentIsNotSilence`; the existing silence checks now send
a
probe before each closed sweep, as the product does.

| check | mutation that turns it red |
|---|---|
| three all-failed sweeps log no "no Hermes-Lite 2 answered" line (and
each address's send warning once) | M1: remove the `if (!probeLeft)
return;` guard |
| one socket sent, one failed: the sweep counts as sent | M3:
`sendFailed` clears the mark |
| one sent sweep, then failed ones, never reach the threshold | M1, and
M2: read the mark without clearing it |
| failed sweeps do not clear the count | M4: an unsent sweep zeroes the
count (also M1, M2) |

Each mutant was built as a scratch copy of `Hl2Discovery.cpp` linked
into
the test; the unmutated control passed with 0 failures. M1 failed 3
checks, M2 2, M3 1, M4 1. "Three sweeps that sent and got no answer log
the line" is the existing third-silent-sweep check; no mutant was run
against it here.

Full ctest at `c818031e`, `-j8`: 756 of 758 passed, 2 failed, both also
on
the serial rerun.
- `peripheral_auth_dialog_interaction_test`: fails on clean main on this
  Mac (#6008); this diff touches no dialog file.
- `unified_title_bar_test`: fails on clean main on this Mac (#6224);
only
  its two known checks failed; this diff touches no title-bar file.

`tools/check_*.py --strict` and `gen_touchpoint_manifest.py --check`
pass.

73, ON8ST

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Oct 12, 2026
… and re-take the Stable snapshot. Principle VIII. (#6396)

## Summary

This brings the user guide (docs.aethersdr.com) up to date with
everything in v26.10.2 and re-takes the Stable snapshot so the default
site shows it. Docs only, apart from the TCI page prose in
`tools/docs/gen_reference.py`; no application source, test or CI
changes.

Every page was audited against the 106 PRs in the release. Every fact
that was added or kept was checked against the source at `f753dbfc4`,
the v26.10.2 release commit, not taken from the PR bodies alone.

## What changed

- **Menu paths.**
- AetherModem, Callsign Lookup, AetherMap and FreeDV Reporter are on
**Tools**. They are created on View and then moved to Tools
(`MainWindow_Menus.cpp:1519-1520`, `:1569-1575`); the style guide's
example says **Tools → AetherMap...**.
  - **Show live CW contacts** is on **View**.
  - Waveforms is on **Tools**, not File.
  - The VUDU applet button was called TXDSP.
  - Labels now match the source's `...` vs `…`.
- **Gaps from this cycle.**
- **Windows and Network Diagnostics:** Windows Firewall rules and the
Network Diagnostics Windows Firewall page (#6289); the `aether.network`
and `aether.nvafx` log categories (#6289, #6308).
  - **Remote access:** Tailscale shim revocation semantics (#6325).
  - **Sleep, App Nap and power throttling (#6195):** per-OS notes.
- **Hermes-Lite 2:** state restore, RX audio at unity, CW at TUNE level,
multi-interface discovery and a discovery troubleshooting entry (#6259,
#6254, #5975, #6009, #6293).
- **ANAN-G2:** AGC-T range, a "What is remembered" section, and supply
voltage and link status (#6282, #6129, #6137, #6211).
  - **NB2** on the HL2 and ANAN-G2 (#5966).
- **DVK:** WAV import requirements, the slot menu without Delete, status
text and licensing (#6247, #6262, #6251).
  - **Copy Assist** from unprocessed audio (#6216).
  - **Live CW contacts** replace the decoder's contact card (#6268).
  - **Status-bar indicators** work from the keyboard (#6267).
- **Volume:** controllers, hotkeys and TCI `volume` follow the active
output (#6129, #6260).
- **Peripherals:** the KPA1500 (#5905), CTR2 link extensions and the
AetherKnob (#6338), TMate 2 udev (#6250), MIDI RADE Modem and FDV modes
(#6031, #6038).
  - **RTL-SDR:** live PPM (#6082).
  - **Icom:** COMP and the MK2 squelch scale (#6174, #6184).
- **Known issues.** Entries whose GitHub issues are closed are removed
(each checked with `gh issue view`). As decided, this includes three
closed today without a demonstrated fix: #5107, #4824 and #5665.
- **US spelling.** `docs-style-guide.md` asked for British spelling; it
now asks for US, by the maintainer's decision, and every page is
converted. The generated TCI pages are converted at their source, the
prose strings in `tools/docs/gen_reference.py`. Code spans, links and
URLs were left alone. UI labels were checked against the source strings,
and none of them are British. Two in-page anchors and two Log Analyzer
rule anchors (`#bnr-is-grayed-out-…`) follow the renamed headings.
- **Stable snapshot.** `snapshot_stable.py 26.10.2` was re-run on the
release tree with these corrections, before `main` was merged in, so
Stable carries no post-release docs. `docs/` also has `main`'s
post-release pages, in US spelling. Stable alone keeps the IC-9700
waterfall-rate entry (#5996): #6402 fixed it after the 26.10.2 tag, so
26.10.2 still has it. The PDF manual already attached to the v26.10.2
release keeps the tag's docs.

## Validation

- `npm ci && npm run build` in `docs/user`: success. This is the
broken-link and broken-anchor check.
- `node tools/docs/test_log_rules.mjs`: 707 checks passed for 45 rules.
- `python3 tools/docs/gen_reference.py --check`: up to date (8 pages).
- `git diff --check`: clean.
- Menu paths on a build of this branch (offscreen, isolated settings, no
radio), `menu list`: `Tools → AetherModem...`, `Tools → Callsign
Lookup...`, `Tools → AetherMap...`, `Tools → FreeDV Reporter...`, `Tools
→ Waveforms...`, `View → Show live CW contacts`.

## Screenshots now out of date (not recaptured)

These need a recapture pass on the radio:
- **Old panadapter title strip (#6386):** `panadapter`, `main-window`,
`first-session-*`, `multi-slice-two-slices`, `split-pair`,
`tnf-markers`, `offscreen-vfo-indicator`, `pan-3d-stacked-trace`,
`default-light-theme`.
- **Pre-canon windows (#6334, #6339, #6341, #6372):** `aetherrx*`,
`aethertx*`, `aethermodem-aprs`, `bnr-panel`, `connect-dialog`,
`connect-by-ip-card`, `connect-icom-rows`, `connect-smartlink-card`,
`rx-agc-calibration`.
- **Other:**
  - `title-bar` (lowercase tab states, #6281);
  - `menu-help` (#6294);
  - `display-panel` (#6371);
  - `support-diagnostics-dialog`;
  - `waveforms-dialog` and `tailnet-remote-access` (show shim 0.4.0);
  - `about-window` (v26.10.1);
  - `radio-setup-qrz`.

## Found during the audit, not fixed here

- **App string bug:** `TailnetShimDialog.cpp:628` tells the user to
"Connect remotely with Connect → Manual"; the real path is **File →
Connect to Radio... → Connect by IP**.
- **ANAN AGC-T tooltips:** the RX applet and VFO tooltips show the
slider position as dB, which is wrong on ANAN. #6282 lists this as a
follow-up.
- **MIDI Master Volume on non-Flex radios:** it is refused while PC
Audio is off, whereas hotkeys, FlexControl and HID drive the line out.
The docs describe the code as it is.
- **RTL mode names:** `rtl-sdr.md` writes "FM-N" and "CW-R", but the
source strings are `FMN` and `CWR`. Left as is until someone confirms
what the mode menu displays.
- **IC-705 COMP readback:** inferred from the MK2 (#6174) and not
verified on an IC-705.
- **Not yet documented:** the #6230 Radio Health row "Forward responses
skipped", and the Store/MSIX package.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants