From 1d6e95bcdd79b0920aa9918a277d0f0c1c6b2e97 Mon Sep 17 00:00:00 2001 From: hoobio <7289249+hoobio@users.noreply.github.com> Date: Tue, 5 May 2026 18:55:32 +1000 Subject: [PATCH] fix(audio): release COM wrappers and adopt singleton view-model lifetime Stops a runaway thread/memory leak that grew to 32k threads + 4 GB over ~11 hours of normal use. Three coupled causes, all fixed here: - AudioSessionService.BuildSnapshot leaked an MMDevice and an AudioSessionControl per audio session per call. Now disposed in finally blocks. - SessionWatcher.OnSessionCreated registered an IAudioSessionEvents sink on each new session and never unregistered it. Now tracked in a ConcurrentBag and unregistered + disposed on watcher disposal, with a _disposed guard to no-op late callbacks. - AudioEndpointService.BuildList had the same MMDevice non-disposal pattern. Same finally-block fix. - HostBuilderExtensions registered RulesViewModel, SessionsViewModel, SettingsViewModel and their pages as Transient. Direct Frame.Content navigation never disposed them, so each page switch leaked a VM that stayed subscribed to audio events forever. Switched to Singleton: one instance per type for the app lifetime, disposed via the host on shutdown. Page state is also preserved across navigation, which makes switching noticeably snappier. --- .../Hosting/HostBuilderExtensions.cs | 12 ++--- .../Services/AudioEndpointService.cs | 4 ++ .../Services/AudioSessionService.cs | 45 ++++++++++++++++++- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/Earmark.App/Hosting/HostBuilderExtensions.cs b/src/Earmark.App/Hosting/HostBuilderExtensions.cs index 8c60d73..de1ae44 100644 --- a/src/Earmark.App/Hosting/HostBuilderExtensions.cs +++ b/src/Earmark.App/Hosting/HostBuilderExtensions.cs @@ -47,13 +47,13 @@ public static HostApplicationBuilder ConfigureEarmark(this HostApplicationBuilde builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddTransient(); - builder.Services.AddTransient(); - builder.Services.AddTransient(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); - builder.Services.AddTransient(); - builder.Services.AddTransient(); - builder.Services.AddTransient(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); return builder; } diff --git a/src/Earmark.Audio/Services/AudioEndpointService.cs b/src/Earmark.Audio/Services/AudioEndpointService.cs index bdc39ab..408c4ca 100644 --- a/src/Earmark.Audio/Services/AudioEndpointService.cs +++ b/src/Earmark.Audio/Services/AudioEndpointService.cs @@ -188,6 +188,10 @@ private List BuildList(DataFlow dataFlow) { _logger.LogWarning(ex, "Failed to map endpoint {Id}", device.ID); } + finally + { + device.Dispose(); + } } return list; diff --git a/src/Earmark.Audio/Services/AudioSessionService.cs b/src/Earmark.Audio/Services/AudioSessionService.cs index d03c761..c955f9c 100644 --- a/src/Earmark.Audio/Services/AudioSessionService.cs +++ b/src/Earmark.Audio/Services/AudioSessionService.cs @@ -112,9 +112,16 @@ private List BuildSnapshot() for (var i = 0; i < sessions.Count; i++) { var session = sessions[i]; - if (TryMap(session, device.ID, out var mapped)) + try { - results.Add(mapped); + if (TryMap(session, device.ID, out var mapped)) + { + results.Add(mapped); + } + } + finally + { + session.Dispose(); } } } @@ -122,6 +129,10 @@ private List BuildSnapshot() { _logger.LogWarning(ex, "Enumerating sessions on {Id} failed", device.ID); } + finally + { + device.Dispose(); + } } return results; @@ -142,15 +153,24 @@ private void AttachAll() foreach (var device in _enumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active)) { + var attached = false; try { var watcher = new SessionWatcher(device, this); _watchers[device.ID] = watcher; + attached = true; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to attach session watcher to {Id}", device.ID); } + finally + { + if (!attached) + { + device.Dispose(); + } + } } } @@ -264,8 +284,10 @@ private sealed class SessionWatcher : IAudioSessionEventsHandler, IDisposable private readonly MMDevice _device; private readonly AudioSessionService _owner; private readonly NotificationClient _notify; + private readonly System.Collections.Concurrent.ConcurrentBag _registeredControls = new(); private readonly string _deviceId; + private bool _disposed; public SessionWatcher(MMDevice device, AudioSessionService owner) { @@ -280,10 +302,16 @@ public SessionWatcher(MMDevice device, AudioSessionService owner) private void OnSessionCreated(object sender, IAudioSessionControl newSession) { + if (_disposed) + { + return; + } + try { var control = new AudioSessionControl(newSession); control.RegisterEventClient(this); + _registeredControls.Add(control); if (_owner.TryMap(control, _deviceId, out var mapped)) { _owner._logger.LogInformation( @@ -310,6 +338,13 @@ public void OnGroupingParamChanged(ref Guid groupingId) { } public void Dispose() { + if (_disposed) + { + return; + } + + _disposed = true; + try { _device.AudioSessionManager.OnSessionCreated -= OnSessionCreated; @@ -319,6 +354,12 @@ public void Dispose() // Ignore. } + while (_registeredControls.TryTake(out var control)) + { + try { control.UnRegisterEventClient(this); } catch { } + try { control.Dispose(); } catch { } + } + _device.Dispose(); _ = _notify; }