Repository navigation
[Bug]: Volume Control - Per-app output routing never shows the current device: the read path is a hardcoded stub returning null #1585
Description
Activity
Re-triaged against current source. The core defect is real and still open; two of the supporting
claims have changed since this was written, and one finding here was not in the original trace.Still exactly as described.
AudioPolicyConfigFactory.GetPersistedDefaultEndpointis still the
stub — body is_ = policyConfig; _ = processId; return null;(AudioPolicyConfig.cs:90-95).
GetSessionOutputDeviceturns that intostring.Empty(AudioMixerService.cs:471), and
SetOutputDeviceFromServiceresolves an empty id toOutputDevices.FirstOrDefault(d => d.IsDefault)
(AudioSessionRowViewModel.cs:238-239). The picker usesDisplayMemberPath="FriendlyName"
(AudioMixerView.xaml:121) over a list built fromPKEY_Device_FriendlyName
(AudioMixerService.cs:387), so the row displays a concrete device name. An app genuinely routed
elsewhere is indistinguishable from one following the default.Changed since this was filed. The last line of the proposed fix — "also call
SetOutputDeviceFromServicefor surviving rows on refresh, not just new ones" — landed in #1919:
RefreshDevicesAsyncnow re-resolves every row's selection after the device list is replaced, gated
onRoutingSupported, on a 10-pass cadence rather than every tick (so the per-second COM cost this
issue warned about does not apply). Rows also hold the live device collection instead of a snapshot.
What that fixed is a selection lost when the list is replaced; it does not feed the picker a real
route, because the read is still a stub.Not in the original trace, and it constrains the fix. Selecting the
IsDefaultentry sends
string.Emptyto the service (AudioSessionRowViewModel.cs:209), and empty is what
SetPersistedDefaultAudioEndpointtreats as clear the override. So the default entry is not merely
a display default — it is the only way a user can stop routing an app, and read and write are
already symmetric around the empty id. Option (a) as written ("leaveSelectedOutputDevicenull and
show a placeholder") therefore has to relocate that action onto the placeholder, or clearing an
override becomes unreachable. That is a product decision about what the picker means, not a
mechanical fix.Nothing about that contract was tested. #1930 pins it in both directions — including that a route the
service cannot resolve selects the default entry and writes nothing back — so whichever option is
chosen, the regression it must not cause is now caught. TheGetPersistedDefaultEndpointcomment is
corrected there too: it claimed the UI shows "System default", which pointed a reader away from this
bug rather than toward it.Leaving this open for the decision between (a) a neutral placeholder plus a relocated clear action,
and (b) the guarded COM read. (b) needs a machine that can run the app against real endpoints.- added a commit that references this issue
on Aug 19, 2026 - added a commit that references this issue
on Sep 3, 2026 Option (a) shipped in #2087, released as
v1.76.4. Option (b) is now #2088.Root cause, as traced in the issue and re-verified.
GetSessionOutputDevicereturned two states where it
needed three: an empty id meant both "this app follows the system default" and "the route could not be read",
andSetOutputDeviceFromServiceturned either into the entry flaggedIsDefault— by name, because the
picker holds real endpoints only. SinceGetPersistedDefaultEndpointis a stub that always returns null, every
picker on every routing-capable build asserted the app was on the default device whatever Windows was doing
with it.The contract now has three states:
return meaning picker shows an endpoint id the app has this override that device string.Emptyread succeeded, no override the IsDefaultentrynullcould not read empty, "Choose a device" Every "we do not know" path in
AudioMixerServicereturns null — disposed, routing unsupported, no
policy-config object, no routing key,COMException— and the?? string.Emptythat flattened them is gone.
The placeholder is aTextBlockover theComboBoxwithIsHitTestVisible="False", bound through the
existingFlexVisconverter, plus a tooltip saying Windows does not report the current device. No new
converter, no new control.One addition to the issue's option (a): an id matching no device in the list is unknown too. A route to an
unplugged endpoint means the app is routed somewhere the list cannot name, so showing the default there is
the same false claim in a rarer case.Writing is untouched. Choosing a device writes that id; choosing the default entry sends empty, which is
what clears an override and remains the only way to stop routing an app. Every write-path test is green.The third clause was deliberately left out, because with the read stubbed it is harmful. The issue also
asks to callSetOutputDeviceFromServicefor surviving rows on refresh. That belongs strictly with option (b):
today it would overwrite whatever the user had just picked withnull, on every reconcile pass, at 1 Hz. It is
specified in #2088 with the cadence constraint the issue's own risk section identifies.Why (b) was split rather than shipped. It needs a slot on an undocumented COM vtable, and its failure mode
is a marshaling mismatch that only appears at the COM boundary — so it can only be verified on my laptop.ARCHITECTURE.mdalready records that constraint for the routing SET path. Shipping it from
the build box would mean claiming a COM round trip works without having seen it work. #2088 is gated on that
verification explicitly.Two problems in the tests, both worth recording.
The theory
ARouteTheServiceCannotResolve_SelectsTheDefaultEntry_AndWritesNothingpinned the bug: one of its
own data rows read "the route-read stub returns empty, so nothing is known about this app's route" while
asserting the default was displayed. It is nowTheReadMirror_ShowsWhatIsKnown_AndWritesNothingacross all
three states, with the no-write assertion unchanged — that half catches a real regression, since a refresh
echoing its own snapshot would re-assert a route every pass and report a routing error the user never caused.And a test for null was not testing null.
RoutableServiceonly configuredGetSessionOutputDevicewhen the
route was non-null, and an unconfigured NSubstitute member returningstringhands backstring.Empty, not
null— so the "could not read" case silently exercised "no override". It is always configured now.Guarded.
TheAudioRouteRead_ReportsUnknownRatherThanTheDefaultholds three things that can each break
alone: the interface keeps the nullable return, the implementation does not collapse it with?? string.Empty,
and the view actually binds the flag. That last one is this codebase's most repeated defect — a property
implemented, unit-tested, and bound by nothing.
EveryViewModelCommand_IsReachableFromTheUicatches it for commands; a bool is not a command.Proven with four mutations: the
IsDefaultfallback restored, the service collapsing unknown to empty, the
view dropping the binding, and the placeholder losingIsHitTestVisibleand swallowing clicks. All four red
for the right reason, every file restored byte-for-byte, green after.README.mdsaid "each app keeps the destination you picked for it" — true of Windows, misleading about the UI
— and now states that Windows does not report the current device.ARCHITECTURE.mdrecords the three-state
contract.
Problem
AudioPolicyConfigFactory.GetPersistedDefaultEndpointis a stub - its body is_ = policyConfig; _ = processId; return null;(Services/AudioPolicyConfig.cs:90-95), documented as deliberate because the out-string marshaling is build-variant. That null is what the UI ultimately renders:AudioMixerService.GetSessionOutputDevicereturns?? string.Emptyfrom it (Services/AudioMixerService.cs:425),MergeIntofeeds that intonewRow.SetOutputDeviceFromService(...)(AudioMixerViewModel.cs:138), and with an empty id that method selectsOutputDevices.FirstOrDefault(d => d.IsDefault)(AudioSessionRowViewModel.cs:201-202). So on every build where routing IS supported, each row's picker always displays the system default device regardless of the app's actual persisted route. Worse, it is misleading after a successful write:SetSessionOutputDevicereally does persist the override (AudioPolicyConfig.cs:103-117,SetPersistedDefaultAudioEndpointfor both Multimedia and Console roles), andSetOutputDeviceFromServiceis only called for new rows inMergeInto(:136-139- surviving rows take theApplyUpdatebranch at:133), so the user's choice looks correct until the app is restarted or the session ends and re-appears, at which point the picker silently reverts to showing the default while the override is still in force in Windows.Expected behavior
Two options, both honest. (a) Cheapest and safest: keep the read unimplemented but stop presenting a fabricated value - when
GetSessionOutputDevicereturns empty, leaveSelectedOutputDevicenull and show a "System default (Windows decides)" placeholder entry in the picker, so the row states what SysManager actually knows. (b) Better: implement the read using the same feature-detect-and-degrade contract that already governs the write path - add theGetPersistedDefaultAudioEndpointvtable slot behind a try/catch that returns null on any COM/marshaling failure (exactly the shape ofTryCreateat:47-83), and pin the endpoint-id round-trip with a unit test the wayToPolicyEndpointIdalready is (:124-129, noted as unit-tested because the format is easy to get wrong). Either way, also callSetOutputDeviceFromServicefor surviving rows on refresh, not just new ones.Rationale
Right now the tab can assert something false about the user's system: it says "Output: Speakers" for an app the user routed to a headset. For the persona that is worse than admitting ignorance - she'll re-pick the device, believe it didn't take, and lose trust in the whole tab. The header copy at
AudioMixerView.xaml:23-24actively promises "Route an app to a specific output device (headset, speakers)", so the display is contradicting the documented feature.Evidence
Services/AudioPolicyConfig.cs:85-95re-read verbatim: the doc comment says "Route-read is intentionally not attempted ... Returns null" and the body is the two discards plusreturn null;. Trace confirmed end-to-end:AudioMixerService.cs:417-429GetSessionOutputDevice->AudioPolicyConfigFactory.GetPersistedDefaultEndpoint(...) ?? string.Empty;AudioMixerViewModel.cs:124-152MergeIntoread in full -SetOutputDeviceFromServiceappears only in theelse(new-row) branch at:138, surviving rows hitrow.ApplyUpdate(info)at:133andApplyUpdate(AudioSessionRowViewModel.cs:126-148) never touchesSelectedOutputDevice;AudioSessionRowViewModel.cs:196-207shows empty id ->FirstOrDefault(d => d.IsDefault). Write path confirmed real atAudioPolicyConfig.cs:111-113(twoSetPersistedDefaultAudioEndpointcalls,hr1 >= 0 && hr2 >= 0).Risk / trade-off
Option (a) is a few lines and cannot regress anything. Option (b) touches an undocumented COM vtable, which is exactly why the author skipped it - it must stay guarded and must not weaken the existing degrade-to-guided-fallback behaviour (
ShowGuidedRouting,AudioSessionRowViewModel.cs:77). Calling the read on every surviving row each refresh adds a COM call per app per second on the 1 s reconcile loop (AudioMixerViewModel.cs:90), so it should be cached or done on tab activation rather than every tick.Affected area
Volume Control