Repository navigation
fix(hl2): log discovery bind failures and silent sweeps. Principle VIII. - #6293
Conversation
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>
There was a problem hiding this comment.
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.mdentry. Correct — thank you. - No sibling target left unfixed.
Hl2Discovery.cppis compiled by exactly two test targets (tests.cmake:2163and:2217) plusCMakeLists.txt:905; both test targets carry thelcDiscoveryobjects after this diff, so nothing is left to fail at link. Confirmed empirically too:ci.yml:306(Linuxbuild) and:1171(check-macos) build the whole tree with no--target, and both are green —check-windows(:796) builds onlyAetherSDRplus five named tests, so it does not cover either target. - CMake contract met.
hl2_discovery_logging_testjoinsAETHER_SETTINGS_CONSUMERS(tests.cmake:7927);app_settings_safety_testwas already there (:8021). - No new public surface — no protocol verb, no settings key, no capability field.
DiscoveryNoticeslands in the existingHl2Discovery.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 noFROZEN_BOOL_COUNTquestion arises. - No
-lines beyond one: the only deletion isif (m_sockets.isEmpty())becoming them_probeSentassignment 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.cppdrivesDiscoveryNoticesdirectly and contains nobind(),listen(),connectToHost(), peer process orFake*radio class.QUdpSocketis linked only becauseHl2Discovery.cppis 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
RadioDiscoverylogs its bind failures at warning (src/core/RadioDiscovery.cpp:67,:90— the latter is what the analyzer's existingdiscovery-bind-failedrule cites) but logs its "nothing has answered yet" condition atqCDebug(:72). Your bind-failure and no-interface lines match the Flex precedent exactly andqCWarningis 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.mjscheck 6 requires that no rule fire ondocs/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 (lcDiscoveryis declaredQtDebugMsgatLogManager.cpp:17, andapplyFilterRules()emits noaether.*.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, becauseaether.discoveryis indefaultOnsoaether.discovery.debug=trueis emitted. But the test installs onlyaether.*.debug=falseand nothing else, sosilenceIsLoggedOnceAfterThresholdwould 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, losesIsUp/IsRunningfor 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 itsfile:line", andtest_log_rules.mjscheck 2 asserts the cited file literally contains the rule'stext. Adjacent-literal concatenation defeats that: only the first fragment is citable. There is no.clang-formatin 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::formatLineruns every line throughredactPii()(src/core/AsyncLogWriter.cpp:328), whose IPv4 rule (:243) keeps only the last octet, so the bundle actually readscannot 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.jsononmainalready carriesdiscovery-bind-failedfor the Flex equivalent atRadioDiscovery.cpp:90; the HL2 twin is the obvious companion, andrules/<id>.log+ the drift check would then keep the string honest. A rebase ontomainis 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:298vs:306— two identifiers for the same interface.bindFailedis keyed oniface.humanReadableName()whileboundInterfacescountsiface.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-109has 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 zeroqC*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 bySupportBundle.cpp:112) reduces the logged IPv4 to its last octet, and your format puts the address space-delimited and not behind thever=/version="lookbehinds that exempt version strings — so it is actually redacted rather than slipping through. The other two values,iface.humanReadableName()andsocket->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-BACKTICKhit atHl2Discovery.h:33is 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.ymland.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.cppis compiled by exactly two test targets and the app; both test targets carry the new objects. Linuxbuildandcheck-macoscompile the whole tree with no--targetand 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 assignm_bindFailuresinendRefresh→ "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. Dropm_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 thesilenceIsLoggedOnceAfterThresholdstatic_assertties 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.
onSweepTimercloses the previous window beforesweepNow()refreshes, so them_interfaceCountprinted in the warning belongs to the refresh that sent the sweep being closed — correct.m_answeredis set for a0x03(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 andstart(), so there is no out-of-bandbeginRefresh/endRefreshpair 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 slackkMissedSweepsBeforeLostgives 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()andloadSettings(): only Debug is globally suppressed,aether.discoveryis default-on, so warnings reach a bundle with noLogManagerchange. 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→DiscoveryNoticeswiring (m_probeSent,m_answered, theif (m_probeSent)gate, thestop()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 ofreset(), sincestop()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
|
Cisco CodeGuard — static analysis of this PR (25 finding(s))
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 |
|
Antares Detector — candidate vulnerable file(s)
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>
|
Thanks for the review. Fixed in
Left for later: the analyzer rule needs 73, ON8ST |
…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
left a comment
There was a problem hiding this comment.
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" stayqCWarning, which matches the FlexRadioDiscoveryprecedent. The test capturesI|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.
boundInterfacesis keyed byhumanReadableName(), the same identifierbindFailed()andbindSucceeded()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>
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>
|
One more commit, Now: If you would rather keep this PR as reviewed, say so and I will move the commit to a follow-up. 73, ON8ST |
jensenpat
left a comment
There was a problem hiding this comment.
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.
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>
… 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>
Refs #6285. This covers item 3 only (Hermes-Lite 2 discovery). Items 1, 2 and 4 are not touched.
Why
Hl2Discovery.cpphas no log statements. Whenbind()fails inrefreshSockets(), 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): threeqCWarning, and the no-reply line atqCInfo, 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:HL2 discovery: cannot bind <interface> <address>: <error>HL2 discovery: no usable IPv4 interface; no probe can be sentHL2 discovery: cannot send a probe from <address>: <error>writeDatagram()failed). Logged once per local address, and again only after a probe has left itHL2 discovery: no Hermes-Lite 2 answered 3 sweeps on K interfaces(interfacewhen 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 countA 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=falserule hides neither Warning nor Info (lcDiscoveryis declaredQtDebugMsg), so noLogManagerchange 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 inHl2Discovery.h/.cppwith no sockets, holds the once-per-condition policy.Hl2Discoveryfeeds it fromrefreshSockets()(each failed and each successful bind, then the socket and interface counts), fromsweepNow()(whether a probe left each socket), fromonSweepTimer()(whether the previous sweep got an answer) and fromstop()(reset).app_settings_safety_testcompilesHl2Discovery.cpp, so it now also links the sharedaether_test_log_managerandaether_test_async_log_writerobjects, which providelcDiscovery.Tests
New:
hl2_discovery_logging_test. It needs no network and no radio. It drivesDiscoveryNoticesand captures the realaether.discoverycategory with the product's defaultaether.*.debug=falserule installed. The checks:reset()(called bystop()) makes all three log againWhat the test does not cover: the calls from
Hl2DiscoveryintoDiscoveryNotices(refreshSockets,sweepNow,onSweepTimer,onReadyRead). Testing those would need real interfaces and a broadcast on the LAN.Results on macOS with Qt 6.12.0:
hl2_discovery_logging_test: 21 of 21 checks pass5f11af00: 665 tests, 663 pass.peripheral_auth_dialog_testandunified_title_bar_testfail at-j8and serially; both also fail on clean main on this machine (feat(peripherals): authenticate direct TGXL, PGXL, and Antenna Genius connections #6008, Unified title bar (#6198): native-platform checks still open — Windows Snap Layouts, #4557 drift, macOS M1, xcb move #6224), and this diff touches none of their filesapp_settings_safety_*scenario passes73, ON8ST
🤖 Generated with Claude Code