Repository navigation
test(audio): pin which device-id list answers presence. Principle VIII. - #6387
Conversation
The hot-plug handler asks two questions of the PC audio device list: which ids are present, and which ids are arrivals. On Linux the arrival list leaves out the audio server's churn (monitor sources, loopbacks, auto-null, echo-cancel and combined devices); the presence list never does. The helpers that build both lists move from MainWindow.cpp into AudioDevicePreferencePolicy.h as pure functions over ids, with the platform rule as a parameter, so every CI lane runs the Linux rule. audioDeviceListChange() builds one direction's change from the enumerable ids and the previous arrival list; MainWindow.cpp keeps only the QAudioDevice to id extraction. No behaviour change intended. The user docs say when a saved virtual device is used again on Linux. Refs aethersdr#6378 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
audioDeviceListChange() takes one struct with named fields, so the two id lists and the two ids cannot be swapped at a call site. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Issue fit
Yes. There is no Fixes issue — this answers the two forward notes left on the approving review of #6378 (merged as 7e080e008, which I confirmed is on main, so this is not building on an unmerged prereq). Both notes are delivered: the id-list helpers move out of MainWindow.cpp's anonymous namespace into src/core/AudioDevicePreferencePolicy.h where a socket-free CTest can reach them, and docs/user/docs/audio-settings.md gains the Linux clause that your own #6378 comment promised ("a saved churn-id device that returns is not in addedIds … it is used again at the next launch"). GOVERNANCE.md wants an RFC for architectural change; moving four file-local helpers into an existing header under an existing touchpoint row is not that, and no new issue was needed.
The "no behaviour change" claim is the load-bearing one, and it holds. I checked each of the four expressions against main:
| question | main |
this PR |
|---|---|---|
| seeded known list | audioDeviceIds() → churn-filtered |
arrivalAudioDeviceIds(enumerated…) → churn-filtered |
addedIds |
filtered ids not in m_known*, device order |
arrivalIds not in knownArrivalIds, same order |
anyRemoved |
!removedAudioDeviceIds(known, filtered).isEmpty() |
known id absent from arrivalIds |
presentIds |
enumeratedAudioDeviceIds() → unfiltered |
change.presentIds = now.enumeratedIds → unfiltered |
Same in each row, and the five Linux patterns are copied byte-for-byte. grep over src/ and tests/ finds no remaining caller of the four removed helpers, so nothing was left behind pointing at the old names.
The doc sentence is accurate, not just plausible: AudioEngine.cpp:1989-1998 restores the saved id by walking the full QMediaDevices::audioInputs()/audioOutputs() with no churn filter, so a saved echo-cancel device really is adopted at launch; and because churn ids can never enter addedIds, RestorePreferred can never fire for one mid-session. "Next time AetherSDR starts, not when it reappears" is exactly right.
Scope
Everything in the diff is explained by the two forward notes. Six files, no stragglers.
| file | what it changes | claimed? | verdict |
|---|---|---|---|
src/core/AudioDevicePreferencePolicy.h |
adds AudioDeviceChurnRule, isLinuxTransientAudioDeviceId, isTransientAudioDeviceId, arrivalAudioDeviceIds, AudioDeviceListSnapshot, audioDeviceListChange |
yes (forward note 1) | in scope |
src/gui/MainWindow.cpp |
deletes the four file-local helpers, calls the header; keeps enumeratedAudioDeviceIds |
yes | in scope |
tests/audio_device_preference_policy_test.cpp |
37 → 55 expect call sites in two new blocks |
yes | in scope |
docs/user/docs/audio-settings.md |
one sentence, the Linux clause | yes (forward note 2) | in scope |
docs/architecture/aetherd-touchpoint-tags.json |
extends the existing row's note |
yes | in scope — required by gen_touchpoint_manifest.py --check |
docs/architecture/aetherd-touchpoints.md |
the regenerated manifest line | yes | in scope — note text matches the JSON exactly, count stays 1 (no new includer) |
No new public surface: no protocol verb, no settings key, no CLI flag, no capability field. CHANGELOG.md correctly untouched. Deleted behaviour check: the removed isTransientAudioDeviceId comment named a symptom — "treating them as 'new devices' is what re-fires the dialog in #2864" — and the replacement header comment keeps both the (#2864) reference and the reason ("never a new mic or speaker to offer"), so nothing a future reader needs was dropped with the #ifdef. No guard, early return or confirmation was removed. Preference check: nothing user-visible changes — no default moved, no UI reordered, no confirmation removed. This is a test-reach refactor plus a doc sentence, not a smuggled preference.
Socket tests: none added, modified or removed. The modified test links Qt6::Core only (tests/tests.cmake:3100-3105) and touches no socket, peer process or Fake* class.
Blockers
None.
Nits
All non-blocking.
-
src/gui/MainWindow.cpp:5298-5299— the arrival list is derived twice, from two independent reads of the default rule.m_knownAudioInputIds = arrivalAudioDeviceIds(presentInputIds); m_knownAudioOutputIds = arrivalAudioDeviceIds(presentOutputIds);audioDeviceListChange()at:5284/:5290already computed exactly this list internally, and both sites default tokHostAudioDeviceChurnRule, so today they agree and the behaviour is right. The coupling is implicit, though: the day someone passes an explicitruletoaudioDeviceListChange()(the parameter exists precisely so they can) and does not also change these two lines, the stored known list and the list arrivals are judged against diverge — and the failure mode is the churn ids counting as arrivals on every check, i.e. the #2864 dialog re-firing, which is the bug this whole filter exists to prevent. Carrying the list it already built on the change makes the two unable to disagree and drops a duplicate pass over the ids:// in AudioDeviceListChange, src/core/AudioDevicePreferencePolicy.h QList<QByteArray> arrivalIds; // the churn-filtered ids: next check's knownArrivalIds
set with
change.arrivalIds = arrivalIds;inaudioDeviceListChange(), after which the handler storesinputChange.arrivalIds/outputChange.arrivalIds. Two files, so I have not written it out as a drop-in patch. -
tests/audio_device_preference_policy_test.cpp:222-223and:240—&&-joined assertions do not localise a failure.expect(isTransientAudioDeviceId("echo-cancel-source") == hostFilters && (arrivalAudioDeviceIds(enumerated) != enumerated) == hostFilters, "this platform's default rule filters on Linux and nowhere else");
This is the one check that can only go red on a Linux lane, and when it does the
[FAIL]line will not say whether the predicate or the list helper disagreed with the host rule. Twoexpectcalls would. Same forarrival.addedIds == … && !arrival.anyRemovedat:240. Cosmetic — the aggregate checks at:276-284genuinely are one assertion about one struct, and those read fine as written. -
Coverage lane, not coverage.
audio_device_preference_policy_testappears in noctest -Rfilter inci.yml, so it runs only infull-suite.yml— post-merge. The Linuxbuildjob did compile theQ_OS_LINUXarm of the header (the lane is green at921c14f2), but thehostFilterscheck that readskHostAudioDeviceChurnRulehas still never executed on Linux and will not before merge. Nothing to fix in the PR; worth stating plainly next to the body's "can only fail for the Linux case on a Linux lane", since the body reads as though a lane will catch it. -
The over-broad substring match is now pinned by a test.
expect(isTransientAudioDeviceId("combined", linuxRule), …)at:201freezescontains("combined"), which also swallows an operator-named PipeWire node likecombined-rig. #6378's thread already ruled that a separate issue and I am not reopening it here — just noting that fixing it later now means editing this check, which is the correct outcome for a pinned behaviour but is worth knowing.
Security audit
Required by the harness (82 CodeGuard findings across the audited files). I ran the security-audit subagent over /tmp/aetherclaude/pr-6387 and verified its conclusions against the files. No confirmed findings. Specifics, because "clean" should be checkable:
CS-VLN-CMDI-AMP/CS-VLN-CMDI-PIPEatAudioDevicePreferencePolicy.h:52,55-58,61,75,101,106— I printed those exact lines. They are C++ reference declarators (const QByteArray& id) and||/&&logical operators inisLinuxTransientAudioDeviceId's return expression. No shell is involved anywhere in the new code. Same class as the knownCS-VLN-CMDI-BACKTICK-on-comments false positive: thetags.jsonhits at:10/:1145are Markdown-quoted identifiers inside JSONnotefields.CS-VLN-PATH-PROCatMainWindow.cpp:6225/:6304(hardcoded/proc/self/statusfor the RSS indicator),CG-PATH-001at:9702-9938,CS-PII-IPat:6917(224.0.0.1, the IANA local-control multicast group WSJT-X defaults to), andCS-INJ-role_overrideat:11754(the C++overridespecifier) are all pre-existing lines outside the two hunks this PR touches, shifted only by the ~100 deleted lines.- The audit file list is wider than the PR.
PanadapterApplet.*,SpectrumOverlayMenu.*andSpectrumWidget.*are not in this PR's six changed files — they arrived onmainwith #6386 (414082197). Their homoglyph/overridehits belong to that change, not this one. - Substantively there is no new attack surface to review: the added code is pure functions over
QByteArrayids produced byQMediaDeviceshost enumeration plus twoAppSettingsstrings, with no exec, no path construction, no parser, no network boundary and no raw buffer.
What I tried to break
Findings below are reasoned from the head checkout at /tmp/aetherclaude/pr-6387; I am headless, so nothing here was built or run.
- The "no behaviour change" claim, expression by expression — the table in Issue fit above. The one I pushed hardest on is
anyRemoved:maincomparedm_known*against the filtered current list, so a churn id disappearing never counted as a removal, and the new loop overknownArrivalIdsagainstarrivalIdspreserves that. Had the new code compared againstenumeratedIdsinstead, every churn id would have looked removed on the first check after seeding; it does not. - The #6378 Linux regression, re-mutated by hand — I walked
presentIds = arrivalIdsthrough the new checks.arrival.presentIds.contains(kEchoCancel)at:245goes red, as do theliveMissingcheck at:250and theacceptedAudioDeviceSelectioncheck at:252. The new block genuinely fails against the defect it is pinning, which is the whole point of the PR; it is not asserting the implementation back to itself. - Sibling call sites and dead additions —
grepacrosssrc/andtests/for all four removed helper names plus the six added ones: the only non-header users areMainWindow.cpp:5226-5299and the test. No second copy of the filter survives elsewhere, and nothing added is unreferenced. The test is registered withadd_testattests/tests.cmake:3105, so it is not an unregistered target. Q_OS_LINUXin a header that includes only<QByteArray>/<QList>—qbytearray.hpullsqglobal.h→qsystemdetection.h, so the macro is defined by the time#ifdefis evaluated. If it were not,kHostAudioDeviceChurnRulewould silently becomeNoneon Linux and re-open #2864; it is, and the green Linuxbuildlane at921c14f2compiled that arm.inline constexprat namespace scope is one entity, so no ODR hazard from the default argument that reads it.- Lifetime and ordering in the handler —
addedInputIds/addedOutputIdsare now references intoinputChange/outputChange, which areconstlocals living to the end ofhandleAudioDeviceListChanged()and are still alive whenAudioDeviceChangeDialogis constructed with them at:5345. Them_known*andm_knownDefault*writes still land after both changes are built and before either decision is taken, so a re-entranthandleAudioDeviceListChanged()(the dialog'sexec()nested event loop, guarded bym_audioDeviceDialogOpenat:5256) cannot see a half-updated pair. - The doc claim, against the startup path — read
AudioEngine.cpp:1989-1998rather than trusting the sentence. Unfiltered enumeration at launch, filtered arrivals at runtime: the asymmetry the doc describes is real. - What I could not check: anything requiring a run — no real device was unplugged, no Linux lane executed the policy test, and the hot-plug handler still has no test of its own. The body states all three plainly, which is the right disclosure;
handleAudioDeviceListChanged()passing the lists correctly intoaudioDeviceListChange()remains read-not-tested, exactly as the Limits section says.
Recommendation
Approve with nits. This is the cleanest kind of follow-up: it converts a reviewer's forward note into a tested seam without touching behaviour, the mutation table in the body is real rather than decorative (I re-derived the row that matters), and the Limits section discloses the gaps instead of papering over them — including the one check that cannot fail on the author's own machine. Next step: nothing blocking. The arrivalIds-on-the-change nit is worth taking now while the struct is fresh, since the failure it forecloses is the #2864 dialog loop; the rest can be ignored or folded in at the author's discretion.
Thanks for coming back to close the notes rather than leaving them as issues — and for naming the macOS-only build honestly a second time.
🤖 aethersdr-agent · cost: $11.6733 · model: claude-opus-5
|
Cisco CodeGuard — static analysis of this PR (87 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: $11.8393 · model: claude-opus-5 |
Review of aethersdr#6387, nit 1: the hot-plug handler derived the arrival list a second time for the stored known ids, from its own read of the default churn rule. AudioDeviceListChange gains arrivalIds, set by audioDeviceListChange(), and the handler stores that list, so the stored ids and the ids arrivals are judged against come from one rule. The test checks arrivalIds under the Linux rule and under none, and that a second change built from the first one's arrivalIds reports no arrival and no removal (aethersdr#2864). Review nit 2: the host-rule check and the addedIds / anyRemoved check are two expect calls each, so a failure names the half that broke. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review. Fixed in
Not changed: Full ctest at 73, ON8ST |
rfoust
left a comment
There was a problem hiding this comment.
Issue fit
Approve with nits; no blockers. This delivers both forward notes from merged PR #6378: making the device-list helpers reachable by a socket-free test and documenting the Linux exception. Presence still uses every enumerated ID; arrivals and removals still use the filtered list. The extraction preserves behavior.
Scope
Everything in the diff is explained by the stated follow-up. All three commits are dated October 11; no unrelated commit or preference change was found.
| File/group | Change | Claimed? | Verdict |
|---|---|---|---|
| AudioDevicePreferencePolicy.h / MainWindow.cpp | Extract helpers and preserve handler wiring | Yes | In scope |
| audio_device_preference_policy_test.cpp | Exercise filtering, presence and successive changes | Yes | In scope |
| audio-settings.md | Document Linux exception | Yes | In scope |
| Touchpoint tags / generated manifest | Describe expanded policy | Yes | In scope |
No new external protocol/settings/capability surface. No CHANGELOG entry. No socket-owning test was added, modified or removed.
Blockers
None.
Nits (non-blocking)
- Narrow the Linux documentation wording to the subset of virtual devices matched by the ID filter. See the inline comment on audio-settings.md:28.
- Replace the assertion that each CI lane tests this rule with wording about the test being able to exercise it on any host. See the inline comment on AudioDevicePreferencePolicy.h:54. The PR body already describes the actual CI boundary accurately.
These are optional wording improvements, not conditions for approval or merge.
Verification and attacks
Reviewed head b0f34a1. GitHub merge ref c96e896 was older than fetched main c2e543c, so I constructed a clean merge with that main in an independent scratch checkout; tested tree a631f4575ad8c798183c9d7c1a2a7610271fbda3.
- ARM64 macOS app build passed with pinned Qt 6.12 and RADE enabled. Verified ARM64 host/system/executable and absence of RNNoise x86 sources. macOS icon packaging required normal system access. The demo launch required the runtime framework path pinned to Qt 6.12 and a session-only bridge token to avoid the local Keychain lookup.
- Registered audio_device_preference_policy_test CTest passed, 63/63 checks; the current-main baseline test passed 37/37.
- Six deliberate mutations failed as expected: filtered presence (3 failures), unfiltered arrivals (3), omitted removals (1), incorrect stored arrival list (2), ignored platform rule (6), and loopback substring instead of prefix (1).
- An independent comparison with the old list logic found equivalent results across 134,162 current/previous/platform combinations, including empty IDs, duplicates and reordered lists. Repeated unchanged lists and simultaneous arrival/removal survived.
- Engine-boundary, capability, command-plane and touchpoint checks produced identical findings on current main and the constructed merge. Generated documentation references and log-rule checks passed.
- Isolated offscreen demo instance PID 81294, explicit review socket, TX disabled: connect list confirmed family sim and serial DEMO-0001; connect/get radio reached one slice and one pan with transmitting=false; get audio reported operational endpoints with NoError; the widget tree contained no AudioDeviceChangeDialog. After sim disconnect, get radio returned connected=false, connectState=idle, transmitting=false. The owned instance was stopped; the operator's instance was untouched.
Runtime smoke evidence is not a Linux hot-plug test. Linux host-default selection, physical unplug/replug and the complete hot-plug handler were not exercised. Their behavior was checked through the extracted policy and caller comparison. No model-invocable automated review pass was available; I performed the second lifecycle/failure-mode pass manually and verified the existing bot alerts rather than adopting them.
Current head checks are green. Recommendation: approve with nits.
rfoust
left a comment
There was a problem hiding this comment.
Approved at b0f34a1 after the posted review and local verification. Both inline wording suggestions are optional and non-blocking; I am resolving my own nit threads to permit the authorized merge. No code changes are required for approval.
…VIII. (#6403) Refs #6387, #6388 Answers rfoust's three optional notes on the approving reviews of #6387 and #6388. ## Limits - One documentation sentence, one comment and five include lines. No behaviour change: no statement, declaration or test registration is touched, and the five headers were already reached through `<QtTest>` and `<QApplication>`. - Built and run on one Mac (macOS, Qt 6.12.0). Per-PR CI compiles these tests but does not run them. The Linux rule the sentence describes is not run on Linux here; it is exercised by `audio_device_preference_policy_test` on this host. - The documentation now names the five ID patterns used by the Linux hot-plug filter. The source matches substrings or a prefix, including custom names; it does not establish any audio server's default device names. - The include pass covers the Qt classes #6387 and #6388 named first in the files they touched. Transitive includes that were there before them are listed below and left alone. ## Change 1. #6387, `docs/user/docs/audio-settings.md:28`: > this reads as applying to all audio-server virtual devices, but the > exception depends on five ID patterns. A custom-named virtual device > outside those patterns restores live. The reviewer's suggested sentence was the starting point. The follow-up commit names all five matching ID patterns, including monitor sources and custom names. `isLinuxTransientAudioDeviceId` leaves those ids out of the arrival list, so a saved device matching one is never seen arriving; at startup the saved id is looked up among all enumerable devices. 2. #6387, `src/core/AudioDevicePreferencePolicy.h:54`: > this comment should describe test portability without claiming every > lane runs it. The suggested wording is taken as written. 3. #6388, `tests/radio_setup_headphone_output_gate_test.cpp:38`: > Include it directly rather than relying on QtTest to supply it > transitively, per the repository explicit-include convention. `<QSignalBlocker>` is included. The same pass over both PRs found four more Qt classes #6388 named first in that file without their header: `QColor`, `QCoreApplication`, `QList`, `QStringList`. They are included too. Found by the same pass and not changed, because the use predates both PRs: `QString` and `QWidget` in that test; `QString` in `mixer_control_availability_test.cpp`, `RadioSetupDialog.cpp` and `TitleBar.cpp`; `QByteArray` and `QList` in `MainWindow.cpp`. The other files of #6387 include what they name. ## Evidence Full ctest before the documentation-only follow-up, at `ac0dd8f6`, `-j8`: 770 of 772 passed (4 skipped: the `nr_stereo_independence_*` set), 2 failed: - `peripheral_auth_dialog_interaction_test`: "dialog 960x680, viewport 658, page minimum 784" (#6008); fails on clean main on this Mac, also on the serial rerun. This diff touches no dialog file. - `vkamp_connection_test`: one of its two known checks, "commands after a disconnect are dropped, not queued" (#5254), at `-j8`; it passed on the serial rerun. `radio_setup_headphone_output_gate_test`, `audio_device_preference_policy_test` and `mixer_control_availability_test` passed. `tools/docs/gen_reference.py --check`, `check_test_registration.py --strict` and `gen_touchpoint_manifest.py --check` pass. At the follow-up commit `f7dd95c9`, `npm ci && npm run build` in `docs/user/` passed, and the source checks remained green. 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Robbie Foust <rfoust@gmail.com>
Refs #6378
Answers the two forward notes on the approving review of #6378: the
id-list helpers move into a header a test can reach, and the user docs
gain the Linux clause.
Limits
asserted on macOS through an explicit parameter; the
Q_OS_LINUXdefault that selects it was not built or run here. One check reads that
default and can only fail for the Linux case on a Linux lane. No per-PR
lane runs it: this test is in no
ctest -Rfilter inci.yml, so itsfirst run on Linux is
full-suite.ymlafter merge. The Linuxbuildjobcompiles that arm.
every caller before and after (below), not on an in-app run.
handleAudioDeviceListChanged()still has no test of its own. What ispinned is the list each question reads: the handler builds each
direction's change with
audioDeviceListChange(), and the test callsthe same function. A handler that filtered the ids before passing them
in would not be caught.
the handler, as it does on
main.What moved
Out of the anonymous namespace in
src/gui/MainWindow.cpp, intosrc/core/AudioDevicePreferencePolicy.h, as pure functions overQByteArrayids:MainWindow.cpp)isTransientAudioDeviceId(id), body under#ifdef Q_OS_LINUXisLinuxTransientAudioDeviceId(id), always compiled;isTransientAudioDeviceId(id, rule)audioDeviceIds(devices)arrivalAudioDeviceIds(enumeratedIds, rule)newlyAddedAudioDeviceIds,removedAudioDeviceIds,containsAudioDeviceId, and the two designated initialisers in the handleraudioDeviceListChange(AudioDeviceListSnapshot, rule); the snapshot's fields are named, so the two lists and the two ids cannot be swappedruleisAudioDeviceChurnRule::{None, Linux}and defaults tokHostAudioDeviceChurnRule, which isLinuxunderQ_OS_LINUXandNoneelsewhere.
MainWindow.cppkeepsenumeratedAudioDeviceIds(devices), theQAudioDeviceto id extraction, and passes its result on.The helpers extend the existing policy header instead of a new one: they
build
AudioDeviceListChange, whosepresentIdsandaddedIdsfieldsthat header already documents, and its test already includes it. The
touchpoint tag note names the addition; the manifest is regenerated.
Why behaviour is the same:
.monitor,pulse_input_loopbackas a prefix,auto_null,echo-cancel,combined), and the default rule isLinuxexactly where the old#ifdefwas true.None, so nothing is churn, as with theold empty body.
setupAudioDeviceChangeMonitor()seeds the two known listswith the arrival list, as before. In the handler, added ids are the
arrival ids not in the known list, in device order; a removal is a
known id not in the arrival list; the known lists are replaced by the
arrival list after both are computed;
presentIdsis every enumerableid. Each is the same expression as on
main, and no other code calledthe removed helpers.
Tests
tests/audio_device_preference_policy_test.cpp: 37 checks become 63. Fromthis PR's review (
b0f34a17): the change carries the arrival list it wasbuilt from, and the handler stores that list, so the two cannot be derived
with different rules; a second check built from the first one's list
reports no arrival (the #2864 loop).
Each new check was shown red by a mutation of the header, in a scratch
build of the test alone (nothing of it is in this PR):
.monitorpulse_input_loopbackauto_nullecho-cancelcombinedLinuxon this platformpresentIdsis the arrival list (the #6378 regression)addedIdsreads the enumerable listDocs
docs/user/docs/audio-settings.md: after "switches back to the saveddevice when that device connects, without asking", one sentence says that
on Linux a saved virtual device of the audio server is used again the
next time the app starts, not when it reappears.
tools/docs/gen_reference.py --checkpasses. The docs site build was notrun here.
Checks
Full ctest at
b0f34a17,-j8: 762 of 763 passed, 1 failed, also on theserial rerun.
peripheral_auth_dialog_interaction_test: fails on clean main on thisMac (feat(peripherals): authenticate direct TGXL, PGXL, and Antenna Genius connections #6008), "Clear code or Status is clipped at the default dialog
size", viewport 658, page minimum 784; this diff touches no dialog
file.
unified_title_bar_testandvkamp_connection_testpassed.audio_device_preference_policy_testpassed.Every
tools/check_*.py --strictthat takes no arguments passes, as dogen_touchpoint_manifest.py --checkandgen_seam_probe_table.py --check.check_a11y.pyonMainWindow.cpp: 0 findings.tools/docs/test_log_rules.mjswas not run (no node on this machine).73, ON8ST
🤖 Generated with Claude Code