From 2329e41d82acee98dc4a9dfa258bb9f7be7c7835 Mon Sep 17 00:00:00 2001 From: Kentaro Hakase Date: Wed, 12 Aug 2026 02:50:43 +0200 Subject: [PATCH] Dispose the source info the processing creates itself AudioInfo subscribes to the localization service in its constructor so its display strings follow a language change, and it unsubscribes only when it is disposed. That service lives for the whole session, so an instance nobody disposes stays reachable through it until the app closes. ProcessAsync analyses the source itself when the caller supplies none, and that instance was assigned to a local and then dropped. Every run down that path left one behind, and each one kept getting notified on every later language change. The rest of the method moves into RenderAsync so the created instance can be held in a using without re-indenting ninety lines. The caller-supplied instance takes the same path but is not disposed there, because it belongs to the queue item that hands it in. The new test pins the contract that makes disposal matter: a disposed AudioInfo no longer reacts to a language change. --- AudioQualityEnhancer.Tests/AudioInfoTests.cs | 54 ++++++++++++++++++++ Services/AudioProcessingService.cs | 30 ++++++++--- 2 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 AudioQualityEnhancer.Tests/AudioInfoTests.cs diff --git a/AudioQualityEnhancer.Tests/AudioInfoTests.cs b/AudioQualityEnhancer.Tests/AudioInfoTests.cs new file mode 100644 index 0000000..54b4f51 --- /dev/null +++ b/AudioQualityEnhancer.Tests/AudioInfoTests.cs @@ -0,0 +1,54 @@ +using System.Globalization; +using AudioQualityEnhancer.Models; +using AudioQualityEnhancer.Services; + +namespace AudioQualityEnhancer.Tests; + +public sealed class AudioInfoTests +{ + /// + /// AudioInfo subscribes to the localization service in its constructor so its display + /// strings follow a language change. That service lives for the whole session, so an + /// instance that is never disposed stays reachable through it for just as long. This + /// pins the contract that makes disposal matter. + /// + [Fact] + public void Dispose_StopsListeningForLanguageChanges() + { + var info = new AudioInfo { Codec = "mp3" }; + var notifications = 0; + var original = LocalizationService.Instance.Culture; + + try + { + // Start from a known culture. Another test class may have left the process on + // English, and assigning the same culture again raises no change at all. + LocalizationService.Instance.Culture = CultureInfo.GetCultureInfo("de"); + info.PropertyChanged += (_, _) => notifications++; + + LocalizationService.Instance.Culture = CultureInfo.GetCultureInfo("en"); + var whileSubscribed = notifications; + Assert.True(whileSubscribed > 0, "A language change has to reach a live instance."); + + info.Dispose(); + LocalizationService.Instance.Culture = CultureInfo.GetCultureInfo("de"); + + Assert.Equal(whileSubscribed, notifications); + } + finally + { + LocalizationService.Instance.Culture = original; + info.Dispose(); + } + } + + [Fact] + public void WithSelectedAudioStream_ReturnsTheSameInstanceWithoutStreams() + { + using var info = new AudioInfo { Codec = "mp3" }; + + // Returning this is what keeps BatchProcessingItem.SelectAudioStream from handing + // an instance to SetAudioInfo that would then be disposed and stored at once. + Assert.Same(info, info.WithSelectedAudioStream(null)); + } +} diff --git a/Services/AudioProcessingService.cs b/Services/AudioProcessingService.cs index a3baf55..21f7475 100644 --- a/Services/AudioProcessingService.cs +++ b/Services/AudioProcessingService.cs @@ -85,18 +85,32 @@ public async Task> ProcessAsync( _fileNameService.CleanupTemporaryOutputFiles(options.OutputDirectory, TimeSpan.FromDays(2)); var sourceInfo = options.SourceInfo; - if (sourceInfo is null) + if (sourceInfo is not null) { - Report(progress, 8, LocalizationService.Instance["Phase_AnalyzingSource"]); - var analysis = await _ffprobeService.AnalyzeAsync(options.InputPath, _logService.Info, cancellationToken); - if (analysis.IsFailure || analysis.Value is null) - { - return Result.Failure(analysis.ErrorMessage ?? LocalizationService.Instance["Error_SourceAnalysisFailed"], analysis.Exception); - } + // Supplied by the caller, so it belongs to the queue item and is disposed with it. + return await RenderAsync(options, sourceInfo, progress, cancellationToken); + } - sourceInfo = analysis.Value; + Report(progress, 8, LocalizationService.Instance["Phase_AnalyzingSource"]); + var analysis = await _ffprobeService.AnalyzeAsync(options.InputPath, _logService.Info, cancellationToken); + if (analysis.IsFailure || analysis.Value is null) + { + return Result.Failure(analysis.ErrorMessage ?? LocalizationService.Instance["Error_SourceAnalysisFailed"], analysis.Exception); } + // Created here, so disposed here. AudioInfo subscribes to the localization + // service in its constructor, and that service outlives the run, so an + // undisposed instance would stay reachable for the rest of the session. + using var ownedSourceInfo = analysis.Value; + return await RenderAsync(options, ownedSourceInfo, progress, cancellationToken); + } + + private async Task> RenderAsync( + ProcessingOptions options, + AudioInfo sourceInfo, + IProgress? progress, + CancellationToken cancellationToken) + { var diskSpaceCheck = EnsureSufficientDiskSpace(options.OutputDirectory, EstimateOutputSizeBytes(options, sourceInfo)); if (diskSpaceCheck.IsFailure) {