Repository navigation
fix: refresh the Volume Control output-device list while the tab is open - #1919
Merged
Merged
Conversation
A headset or speaker plugged in after opening Volume Control never appeared in any app's "play on" picker, for the whole life of the app. Two independent causes, both needed fixing: * `RefreshDevicesAsync` had exactly one caller, `InitAsync`. The list was read once, at tab-open, and never again — and a tab's view model lives as long as the process. * Every row received `OutputDevices.ToList()`, a snapshot. So even a refreshed view model list could not have reached a picker that already existed. Rows now receive the live `BulkObservableCollection`, and devices are re-enumerated every tenth reconcile pass (~10s) rather than every pass: enumerating endpoints is COM-heavy and reconcile runs at 1 Hz, while plugging hardware in is a human-timescale event. Refreshing also had to stop losing each row's choice. `ReplaceWith` clears and refills, and `AudioDevice` is a record whose equality covers `IsDefault`, so moving the Windows default made every element come back unequal and the bound `SelectedItem` would fall to null. Selections are now re-resolved by endpoint id after the replacement, gated on the row's own routing capability so the system-sounds pseudo-session — which shows no picker — is not handed a destination. Four tests, each proven to fail without its fix (5 mutations, file restored byte-for-byte). None activates the tab, so the background loops stay parked and the pass count is exactly what the test drives.
laurentiu021
force-pushed
the
fix/audio-device-list-goes-stale
branch
from
August 18, 2026 12:35
0ac332c to
1b767d9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What a user sees
Open Volume Control. Plug in a USB headset. Open any app's "play on" picker — the headset is not
there. It never will be, for as long as SysManager stays running.
The defect
Two independent causes, and fixing either one alone would have left the bug in place.
1. The list was read once.
RefreshDevicesAsynchad exactly one caller:ReconcileAsyncruns at 1 Hz and refreshes sessions, never devices. A tab's view model livesas long as the process, so "once at tab-open" meant once per app launch.
2. Each row got a snapshot. Even a refreshed view-model list could not have reached a picker
that already existed:
ToList()was not obviously wrong — rows are recreated on every reconcile pass, so the copy looksshort-lived. But
MergeIntodeliberately preserves surviving rows (that is what keeps a draggedslider from being dropped), so a row for an app that keeps playing is never rebuilt.
The fix
Rows receive the live
BulkObservableCollection, and devices are re-enumerated every tenthreconcile pass.
Ten seconds, not one, on purpose. Enumerating endpoints is COM-heavy and reconcile runs at
1 Hz. Plugging hardware in is a human-timescale event; an app starting to play is not — hence the
two cadences. Trading a stale list for a COM enumeration every second would have been a worse bug
than the one being fixed, which is why the cadence has its own test.
The part that took a wrong turn
Refreshing had to stop losing each row's choice, and the first version of that test passed with or
without the fix:
ReplaceWithclears and refills, and a boundSelectedItemsurvives only while an equalelement comes back.
AudioDeviceis a record, so its equality coversIsDefault— move theWindows default output and every element is unequal, the real
ComboBoxdrops the selection, andthe picker forgets where the user sent that app. But a unit test has no
ComboBox: nothing clearsthe VM's property, so asserting the id alone is green either way.
What discriminates is membership — without the re-resolve the selected instance is absent from
the new list, which is precisely the condition that makes the real control drop it:
The re-resolve is gated on
row.RoutingSupported, matching the construction-time gate inMergeInto: the system-sounds pseudo-session shows no picker because Windows cannot reroute it, soit must not be handed a destination nothing can act on. That gate has its own test too.
Verification
Red proof: 5 mutations, the touched file restored byte-for-byte.
OutputDevices.ToList()again (half the shipped defect)DeviceAddedAfterTheTabOpened_ReachesAnAlreadyExistingRowsPickerReconcileAsync(the other half)ReplaceWithRefreshingDevices_KeepsEachRowsChoice_EvenWhenTheDefaultMovesReconcile_DoesNotReEnumerateDevicesOnEveryPassRefreshingDevices_LeavesTheSystemSoundsRowWithoutADestinationEach mutation reddened exactly its own test and nothing else. Mutation 2 removes the pass counter
with the block it feeds — left behind it is
CS0169under warnings-as-errors, and a mutation thatdoes not compile proves nothing.
No test activates the tab.
ReconcileAsyncdoes not need it, and leaving the loops parked keepsthe pass count exactly what the test drives. Setting
IsActive = truereleases the peak-meter andreconcile loops, and that is what made
UpdatePeaks_ReadsEveryRowInOneServiceCallfail the v1.65.16release: a substitute is not thread-safe while a live loop records against it. The one test here that
counts calls counts inside the stub, not with
Received(n).Other checks: all four projects build 0 errors / 0 warnings;
dotnet format --verify-no-changesexit 0 on both; the audio class plus every architecture guard run 79 green (the 2 reds are the
scratch harness's own files, not repo code);
author headers present on both touched code files.
Not in this PR
The remaining items from the same audio review, each still to be checked against current source before
it drives a change:
SetVolume/SetMutetake the same gate the 1 Hz COM enumeration holds, on theUI thread, once per mouse-move during a drag;
AudioSessionRowViewModel.IsActiveis computed andchange-notified but bound by no XAML;
ReconcileCommandis dead surface; two COM references leak oncast-failure branches; and an
InvalidCastExceptionescaping init kills both loops silently with nobound command to recover.