diff --git a/AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs b/AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs index b54534d..e6165dc 100644 --- a/AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs +++ b/AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs @@ -79,6 +79,30 @@ public void BuildReport_LowHeadroomOutputIsWarning() Assert.Contains(report.Findings, finding => finding.Kind == AudioComparisonFindingKind.LowHeadroom); } + [Fact] + public void CreateValidationResult_CriticalReportFailsAndPreservesReport() + { + var report = CreateComparisonReport(AudioComparisonStatus.Critical); + + var result = AudioValidationService.CreateValidationResult(report); + + Assert.True(result.IsFailure); + Assert.Same(report, result.Value); + Assert.Equal(report.Summary, result.ErrorMessage); + } + + [Fact] + public void CreateValidationResult_WarningReportSucceedsAndPreservesReport() + { + var report = CreateComparisonReport(AudioComparisonStatus.Warning); + + var result = AudioValidationService.CreateValidationResult(report); + + Assert.True(result.IsSuccess); + Assert.Same(report, result.Value); + Assert.Null(result.ErrorMessage); + } + [Fact] public void BuildReport_DurationMismatchIsCriticalWhenLarge() { @@ -350,6 +374,20 @@ private static ProcessingOptions CreateOptions(AudioInfo sourceInfo, AudioPreset }; } + private static AudioComparisonReport CreateComparisonReport(AudioComparisonStatus status) + { + return new AudioComparisonReport( + status, + status.ToString(), + $"{status} summary", + @"C:\audio\output.flac", + outputInfo: null, + outputDiagnostics: null, + findings: Array.Empty(), + metrics: Array.Empty(), + outputDiagnosticsSkipped: false); + } + private static AudioInfo CreateInfo( string codec, bool isLossy, diff --git a/CHANGELOG.md b/CHANGELOG.md index 06feeb7..66dd35b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ # Changelog -## 0.17.0 - 2026-06-13 +## Unreleased + +### Fixed + +- A result validation that ends with a critical finding now fails the affected file instead of reporting it as exported successfully. The report stays attached to the item, so the findings remain visible in the validation panel. +- A run that was stopped is no longer misreported: an inactivity timeout now ends the wait for the process directly and is reported as a timeout, while only an actual user cancellation is reported as one. +- After a process is terminated the app waits briefly for it to exit and stops the output readers before reading what was captured, so the collected output can no longer be read while it is still being written. + +### Changed + +- The release packaging script now requires an explicit `-Version` in `major.minor.patch` form instead of falling back to a stale default version. ### Added diff --git a/README.md b/README.md index 1e26d96..be16b24 100644 --- a/README.md +++ b/README.md @@ -170,19 +170,19 @@ Die Tests prüfen Parser, Dateilogik, Batch-Logik, Analyse-Bewertung, FFmpeg-Arg Portable ZIP ohne FFmpeg: ```powershell -.\scripts\package-release.ps1 -Version 0.16.0 +.\scripts\package-release.ps1 -Version ``` Portable ZIP mit FFmpeg und FFprobe: ```powershell -.\scripts\package-release.ps1 -Version 0.16.0 -IncludeFFmpeg +.\scripts\package-release.ps1 -Version -IncludeFFmpeg ``` Pakete prüfen: ```powershell -.\scripts\verify-release-package.ps1 -Version 0.16.0 -RequireFFmpegPackage +.\scripts\verify-release-package.ps1 -Version -RequireFFmpegPackage ``` Die fertigen ZIP-Dateien liegen danach in `artifacts`. Zu jedem ZIP wird eine passende `.sha256.txt`-Datei erzeugt. @@ -190,7 +190,7 @@ Die fertigen ZIP-Dateien liegen danach in `artifacts`. Zu jedem ZIP wird eine pa Checksum eines Downloads prüfen: ```powershell -Get-FileHash .\AudioQualityEnhancer-0.16.0-win-x64.zip -Algorithm SHA256 +Get-FileHash .\AudioQualityEnhancer--win-x64.zip -Algorithm SHA256 ``` Der angezeigte SHA256-Wert muss zum Inhalt der passenden `.sha256.txt`-Datei im Release passen. diff --git a/Services/AudioValidationService.cs b/Services/AudioValidationService.cs index dcbf9a6..aeb8dbe 100644 --- a/Services/AudioValidationService.cs +++ b/Services/AudioValidationService.cs @@ -88,7 +88,14 @@ public async Task> ValidateAsync( outputPath); _logService.Info(LocalizationService.Instance.Format("Log_ValidationCompleteFormat", reportResult.StatusText)); - return Result.Success(reportResult); + return CreateValidationResult(reportResult); + } + + internal static Result CreateValidationResult(AudioComparisonReport report) + { + return report.Status == AudioComparisonStatus.Critical + ? Result.Failure(report.Summary, value: report) + : Result.Success(report); } internal static Result ValidateOutputFile(string outputPath) diff --git a/Services/ProcessRunner.cs b/Services/ProcessRunner.cs index 7d586f2..9121fda 100644 --- a/Services/ProcessRunner.cs +++ b/Services/ProcessRunner.cs @@ -6,6 +6,9 @@ namespace AudioQualityEnhancer.Services; internal sealed class ProcessRunner : IProcessRunner { + private static readonly TimeSpan TerminationWaitTimeout = TimeSpan.FromSeconds(2); + private static readonly TimeSpan ReaderDrainTimeout = TimeSpan.FromSeconds(2); + public async Task RunAsync(ProcessRunOptions options, CancellationToken cancellationToken) { using var process = new Process(); @@ -66,6 +69,10 @@ public async Task RunAsync(ProcessRunOptions options, Cancellatio }; using var watchdogStop = new CancellationTokenSource(); + using var timeoutCancellation = new CancellationTokenSource(); + using var exitCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCancellation.Token); Task? watchdogTask = null; try @@ -80,27 +87,39 @@ public async Task RunAsync(ProcessRunOptions options, Cancellatio process, inactivityTimeout, () => Interlocked.Read(ref lastActivityTimestamp), - () => Interlocked.Exchange(ref timedOutFlag, 1), + () => + { + Interlocked.Exchange(ref timedOutFlag, 1); + timeoutCancellation.Cancel(); + }, watchdogStop.Token); } - await process.WaitForExitAsync(cancellationToken); - await Task.WhenAll(outputClosed.Task, errorClosed.Task); + await process.WaitForExitAsync(exitCancellation.Token).ConfigureAwait(false); + await TryWaitForReadersAsync(process, outputClosed.Task, errorClosed.Task).ConfigureAwait(false); return CreateResult(process, stdout, stderr, startedAt, wasCancelled: false, timedOut: timedOutFlag == 1); } catch (OperationCanceledException) { + var timedOut = timedOutFlag == 1; TryKill(process); - await TryWaitForReadersAsync(outputClosed.Task, errorClosed.Task); - return CreateResult(process, stdout, stderr, startedAt, wasCancelled: true, timedOut: timedOutFlag == 1); + await TryWaitForExitAsync(process).ConfigureAwait(false); + await TryWaitForReadersAsync(process, outputClosed.Task, errorClosed.Task).ConfigureAwait(false); + return CreateResult( + process, + stdout, + stderr, + startedAt, + wasCancelled: !timedOut && cancellationToken.IsCancellationRequested, + timedOut); } finally { watchdogStop.Cancel(); if (watchdogTask is not null) { - await watchdogTask; + await watchdogTask.ConfigureAwait(false); } } } @@ -121,7 +140,12 @@ private static async Task WatchForInactivityAsync( { while (!stopToken.IsCancellationRequested) { - await Task.Delay(interval, stopToken); + await Task.Delay(interval, stopToken).ConfigureAwait(false); + if (HasExited(process)) + { + return; + } + var idleTime = Stopwatch.GetElapsedTime(getLastActivityTimestamp()); if (idleTime >= timeout) { @@ -147,13 +171,21 @@ private static ProcessResult CreateResult( { return new ProcessResult( TryGetExitCode(process, wasCancelled || timedOut ? -1 : 0), - stdout.ToString(), - stderr.ToString(), + Snapshot(stdout), + Snapshot(stderr), DateTimeOffset.Now - startedAt, wasCancelled, TimedOut: timedOut); } + private static string Snapshot(StringBuilder value) + { + lock (value) + { + return value.ToString(); + } + } + private static int TryGetExitCode(Process process, int fallback) { try @@ -166,16 +198,65 @@ private static int TryGetExitCode(Process process, int fallback) } } - private static async Task TryWaitForReadersAsync(Task outputClosed, Task errorClosed) + private static async Task TryWaitForExitAsync(Process process) + { + var startedAt = Stopwatch.GetTimestamp(); + while (!HasExited(process) && Stopwatch.GetElapsedTime(startedAt) < TerminationWaitTimeout) + { + await Task.Delay(TimeSpan.FromMilliseconds(25)).ConfigureAwait(false); + } + } + + private static async Task TryWaitForReadersAsync(Process process, Task outputClosed, Task errorClosed) { try { - await Task.WhenAll(outputClosed, errorClosed).WaitAsync(TimeSpan.FromSeconds(2)); + await Task.WhenAll(outputClosed, errorClosed).WaitAsync(ReaderDrainTimeout).ConfigureAwait(false); + return; } catch { // Best effort drain after cancellation. } + + TryCancelOutputRead(process); + TryCancelErrorRead(process); + } + + private static bool HasExited(Process process) + { + try + { + return process.HasExited; + } + catch + { + return true; + } + } + + private static void TryCancelOutputRead(Process process) + { + try + { + process.CancelOutputRead(); + } + catch + { + // The reader is already closed or was never started. + } + } + + private static void TryCancelErrorRead(Process process) + { + try + { + process.CancelErrorRead(); + } + catch + { + // The reader is already closed or was never started. + } } private static void TryKill(Process process) diff --git a/scripts/package-release.ps1 b/scripts/package-release.ps1 index a43c353..7bc805c 100644 --- a/scripts/package-release.ps1 +++ b/scripts/package-release.ps1 @@ -1,5 +1,7 @@ param( - [string]$Version = "0.2.0", + [Parameter(Mandatory = $true)] + [ValidatePattern('^\d+\.\d+\.\d+$')] + [string]$Version, [string]$Runtime = "win-x64", [switch]$IncludeFFmpeg )