Skip to content

fix: refresh the Volume Control output-device list while the tab is open - #1919

Merged
laurentiu021 merged 1 commit into
mainfrom
fix/audio-device-list-goes-stale
Aug 18, 2026
Merged

laurentiu021 merged 1 commit into
mainfrom
fix/audio-device-list-goes-stale

Conversation

@laurentiu021

@laurentiu021 laurentiu021 commented Aug 18, 2026 •

Copy link
Copy Markdown
Owner

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. RefreshDevicesAsync had exactly one caller:

// InitAsync — the only call site
await RefreshDevicesAsync().ConfigureAwait(true);

ReconcileAsync runs at 1 Hz and refreshes sessions, never devices. A tab's view model lives
as 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:

var newRow = new AudioSessionRowViewModel(
    _service, info, OutputDevices.ToList(), RoutingSupported, ...);
//                                ^^^^^^^^ frozen at the moment this row was created

ToList() was not obviously wrong — rows are recreated on every reconcile pass, so the copy looks
short-lived. But MergeInto deliberately preserves surviving rows (that is what keeps a dragged
slider 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 tenth
reconcile 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:

public sealed record AudioDevice(string Id, string FriendlyName, bool IsDefault);

ReplaceWith clears and refills, and a bound SelectedItem survives only while an equal
element comes back. AudioDevice is a record, so its equality covers IsDefault — move the
Windows default output and every element is unequal, the real ComboBox drops the selection, and
the picker forgets where the user sent that app. But a unit test has no ComboBox: nothing clears
the 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:

Assert.Equal("{hdst}", row.SelectedOutputDevice?.Id);
Assert.Contains(row.SelectedOutputDevice, row.OutputDevices);   // <- the assertion that can fail

The re-resolve is gated on row.RoutingSupported, matching the construction-time gate in
MergeInto: the system-sounds pseudo-session shows no picker because Windows cannot reroute it, so
it 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.

Mutation Must go red
rows get OutputDevices.ToList() again (half the shipped defect) DeviceAddedAfterTheTabOpened_ReachesAnAlreadyExistingRowsPicker
remove the throttled refresh from ReconcileAsync (the other half) the same test
drop the selection re-resolve after ReplaceWith RefreshingDevices_KeepsEachRowsChoice_EvenWhenTheDefaultMoves
refresh devices on every pass Reconcile_DoesNotReEnumerateDevicesOnEveryPass
drop the routing gate on the re-resolve RefreshingDevices_LeavesTheSystemSoundsRowWithoutADestination

Each mutation reddened exactly its own test and nothing else. Mutation 2 removes the pass counter
with the block it feeds — left behind it is CS0169 under warnings-as-errors, and a mutation that
does not compile proves nothing.

No test activates the tab. ReconcileAsync does not need it, and leaving the loops parked keeps
the pass count exactly what the test drives. Setting IsActive = true releases the peak-meter and
reconcile loops, and that is what made UpdatePeaks_ReadsEveryRowInOneServiceCall fail the v1.65.16
release: 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-changes
exit 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/SetMute take the same gate the 1 Hz COM enumeration holds, on the
UI thread, once per mouse-move during a drag; AudioSessionRowViewModel.IsActive is computed and
change-notified but bound by no XAML; ReconcileCommand is dead surface; two COM references leak on
cast-failure branches; and an InvalidCastException escaping init kills both loops silently with no
bound command to recover.

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
laurentiu021 force-pushed the fix/audio-device-list-goes-stale branch from 0ac332c to 1b767d9 Compare August 18, 2026 12:35
@laurentiu021
laurentiu021 merged commit 5452cb6 into main Aug 18, 2026
5 checks passed
@laurentiu021
laurentiu021 deleted the fix/audio-device-list-goes-stale branch August 18, 2026 12:52
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.

1 participant