Skip to content

[Bug]: Volume Control - Per-app output routing never shows the current device: the read path is a hardcoded stub returning null #1585

Description

@laurentiu021

Problem

AudioPolicyConfigFactory.GetPersistedDefaultEndpoint is 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.GetSessionOutputDevice returns ?? string.Empty from it (Services/AudioMixerService.cs:425), MergeInto feeds that into newRow.SetOutputDeviceFromService(...) (AudioMixerViewModel.cs:138), and with an empty id that method selects OutputDevices.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: SetSessionOutputDevice really does persist the override (AudioPolicyConfig.cs:103-117, SetPersistedDefaultAudioEndpoint for both Multimedia and Console roles), and SetOutputDeviceFromService is only called for new rows in MergeInto (:136-139 - surviving rows take the ApplyUpdate branch 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 GetSessionOutputDevice returns empty, leave SelectedOutputDevice null 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 the GetPersistedDefaultAudioEndpoint vtable slot behind a try/catch that returns null on any COM/marshaling failure (exactly the shape of TryCreate at :47-83), and pin the endpoint-id round-trip with a unit test the way ToPolicyEndpointId already is (:124-129, noted as unit-tested because the format is easy to get wrong). Either way, also call SetOutputDeviceFromService for 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-24 actively promises "Route an app to a specific output device (headset, speakers)", so the display is contradicting the documented feature.

Evidence

Services/AudioPolicyConfig.cs:85-95 re-read verbatim: the doc comment says "Route-read is intentionally not attempted ... Returns null" and the body is the two discards plus return null;. Trace confirmed end-to-end: AudioMixerService.cs:417-429 GetSessionOutputDevice -> AudioPolicyConfigFactory.GetPersistedDefaultEndpoint(...) ?? string.Empty; AudioMixerViewModel.cs:124-152 MergeInto read in full - SetOutputDeviceFromService appears only in the else (new-row) branch at :138, surviving rows hit row.ApplyUpdate(info) at :133 and ApplyUpdate (AudioSessionRowViewModel.cs:126-148) never touches SelectedOutputDevice; AudioSessionRowViewModel.cs:196-207 shows empty id -> FirstOrDefault(d => d.IsDefault). Write path confirmed real at AudioPolicyConfig.cs:111-113 (two SetPersistedDefaultAudioEndpoint calls, 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

Activity

  1. laurentiu021 commented on Aug 19, 2026

    @laurentiu021
    OwnerAuthor

    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.GetPersistedDefaultEndpoint is still the
    stub — body is _ = policyConfig; _ = processId; return null; (AudioPolicyConfig.cs:90-95).
    GetSessionOutputDevice turns that into string.Empty (AudioMixerService.cs:471), and
    SetOutputDeviceFromService resolves an empty id to OutputDevices.FirstOrDefault(d => d.IsDefault)
    (AudioSessionRowViewModel.cs:238-239). The picker uses DisplayMemberPath="FriendlyName"
    (AudioMixerView.xaml:121) over a list built from PKEY_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
    SetOutputDeviceFromService for surviving rows on refresh, not just new ones" — landed in #1919:
    RefreshDevicesAsync now re-resolves every row's selection after the device list is replaced, gated
    on RoutingSupported, 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 IsDefault entry sends
    string.Empty to the service (AudioSessionRowViewModel.cs:209), and empty is what
    SetPersistedDefaultAudioEndpoint treats 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 ("leave SelectedOutputDevice null 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. The GetPersistedDefaultEndpoint comment 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.

  2. added a commit that references this issue on Sep 3, 2026
    e7e7555
  3. laurentiu021 commented on Sep 3, 2026

    @laurentiu021
    OwnerAuthor

    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. GetSessionOutputDevice returned two states where it
    needed three: an empty id meant both "this app follows the system default" and "the route could not be read",
    and SetOutputDeviceFromService turned either into the entry flagged IsDefault — by name, because the
    picker holds real endpoints only. Since GetPersistedDefaultEndpoint is 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.Empty read succeeded, no override the IsDefault entry
    null could not read empty, "Choose a device"

    Every "we do not know" path in AudioMixerService returns null — disposed, routing unsupported, no
    policy-config object, no routing key, COMException — and the ?? string.Empty that flattened them is gone.
    The placeholder is a TextBlock over the ComboBox with IsHitTestVisible="False", bound through the
    existing FlexVis converter, 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 call SetOutputDeviceFromService for surviving rows on refresh. That belongs strictly with option (b):
    today it would overwrite whatever the user had just picked with null, 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.md already 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_AndWritesNothing pinned 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 now TheReadMirror_ShowsWhatIsKnown_AndWritesNothing across 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. RoutableService only configured GetSessionOutputDevice when the
    route was non-null, and an unconfigured NSubstitute member returning string hands back string.Empty, not
    null — so the "could not read" case silently exercised "no override". It is always configured now.

    Guarded. TheAudioRouteRead_ReportsUnknownRatherThanTheDefault holds 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_IsReachableFromTheUi catches it for commands; a bool is not a command.

    Proven with four mutations: the IsDefault fallback restored, the service collapsing unknown to empty, the
    view dropping the binding, and the placeholder losing IsHitTestVisible and swallowing clicks. All four red
    for the right reason, every file restored byte-for-byte, green after.

    README.md said "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.md records the three-state
    contract.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions